diff --git a/.coveragerc b/.coveragerc index fa090b4934f..e10affed596 100644 --- a/.coveragerc +++ b/.coveragerc @@ -909,6 +909,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9d13c07301e..7c3a42aaaa1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9b9c8babb9..2255b3f145c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7d9d4cd901..c9e6bb8fcc8 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.8 + uses: github/codeql-action/init@v2.22.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.8 + uses: github/codeql-action/analyze@v2.22.9 with: category: "/language:python" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d..b51550767b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + - name: 60 days stale PRs policy + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. @@ -53,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802..c8e25cc83ea 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.strict-typing b/.strict-typing index eeaee4d2f37..2b8703d2e57 100644 --- a/.strict-typing +++ b/.strict-typing @@ -153,6 +153,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* @@ -317,6 +318,7 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* diff --git a/CODEOWNERS b/CODEOWNERS index 8ffbdfea876..fce99d00bd9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -524,6 +524,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger +/tests/components/holiday/ @jrieger /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa @@ -930,6 +932,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund diff --git a/Dockerfile b/Dockerfile index 97eeb5b0dfa..43b21ab3ba8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ - S6_SERVICES_GRACETIME=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0998ac6274c..83b2f18719f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .const import ( from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 4f2a9a8d99b..ed9029d1c2c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field from enum import StrEnum import logging from pathlib import Path -from queue import Queue +from queue import Empty, Queue from threading import Thread import time from typing import TYPE_CHECKING, Any, Final, cast @@ -1010,8 +1010,8 @@ class PipelineRun: self.tts_engine = engine self.tts_options = tts_options - async def text_to_speech(self, tts_input: str) -> str: - """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" + async def text_to_speech(self, tts_input: str) -> None: + """Run text-to-speech portion of pipeline.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -1058,8 +1058,6 @@ class PipelineRun: PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) - return tts_media.url - def _capture_chunk(self, audio_bytes: bytes | None) -> None: """Forward audio chunk to various capturing mechanisms.""" if self.debug_recording_queue is not None: @@ -1246,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc( # Chunk of 16-bit mono audio at 16Khz if wav_writer is not None: wav_writer.writeframes(message) + except Empty: + pass # occurs when pipeline has unexpected error except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Unexpected error in debug recording thread") finally: diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 83f99ecc76a..228da7f1a36 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__) _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") -_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_FuncType = Callable[ + [_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]] +] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] @@ -81,7 +83,7 @@ def handle_errors_and_zip( if isinstance(data, dict): return dict(zip(keys, list(data.values()))) - if not isinstance(data, list): + if not isinstance(data, (list, tuple)): raise UpdateFailed("Received invalid data type") return dict(zip(keys, data)) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 43e3bd2ad5c..39abba4ada5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): power_watts = self.client.measure(3, True) temperature_c = self.client.measure(21) energy_wh = self.client.cumulated_energy(5) + [alarm, *_] = self.client.alarms() except AuroraTimeoutError: self.available = False _LOGGER.debug("No response from inverter (could be dark)") @@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): data["instantaneouspower"] = round(power_watts, 1) data["temp"] = round(temperature_c, 1) data["totalenergy"] = round(energy_wh / 1000, 2) + data["alarm"] = alarm self.available = True finally: diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 0e7d0c06a4e..80b0fd656b6 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from aurorapy.mapping import Mapping as AuroraMapping + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,8 +38,16 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502..63ea1cfefd4 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6d..ff0fe43ea26 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 101436c0f31..e0af12514da 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -7,7 +7,7 @@ "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." + "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } } }, diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index ddf57aa6eee..63a1c1b45f0 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -215,7 +215,7 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -225,7 +225,6 @@ class DomainBlueprints: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd5..329b597d515 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,12 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -59,7 +65,11 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice +from .base_scanner import ( + BaseHaScanner, + BluetoothScannerDevice, + HomeAssistantRemoteScanner, +) from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -71,15 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -103,7 +107,7 @@ __all__ = [ "async_scanner_count", "async_scanner_devices_by_address", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", @@ -139,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -280,8 +286,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] + scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978..174e5c66ce8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e9..3a13dda28a8 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f5..00000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..afdd26a2001 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,29 +9,25 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import BluetoothScanningMode from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 637ebbaf867..8267a73fd71 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,19 +1,14 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator -from contextlib import contextmanager +from collections.abc import Callable from dataclasses import dataclass -import datetime -from datetime import timedelta -import logging -from typing import Any, Final +from typing import Any from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name +from bluetooth_adapters import DiscoveredDeviceAdvertisementData +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -23,20 +18,8 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) @dataclass(slots=True) @@ -48,150 +31,17 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" +class HomeAssistantRemoteScanner(BaseHaRemoteScanner): + """Home Assistant remote BLE scanner. + + This is the only object that should know about + the hass object. + """ __slots__ = ( "hass", - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - ) - - def __init__( - self, - hass: HomeAssistant, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.hass = hass - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", "_storage", + "_cancel_stop", ) def __init__( @@ -204,50 +54,36 @@ class BaseHaRemoteScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + self.hass = hass assert models.MANAGER is not None self._storage = models.MANAGER.storage + self._cancel_stop: CALLBACK_TYPE | None = None + super().__init__(scanner_id, name, new_info_callback, connector, connectable) @hass_callback def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" + super().async_setup() if history := self._storage.async_get_advertisement_history(self.source): self._discovered_device_advertisement_datas = ( history.discovered_device_advertisement_datas ) self._discovered_device_timestamps = history.discovered_device_timestamps # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) + self._async_expire_devices() - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( + self._cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history ) - self._async_setup_scanner_watchdog() + return self._unsetup - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel + @hass_callback + def _unsetup(self) -> None: + super()._unsetup() + self._async_save_history() + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None @hass_callback def _async_save_history(self, event: Event | None = None) -> None: @@ -262,146 +98,10 @@ class BaseHaRemoteScanner(BaseHaScanner): ), ) - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - try: - prev_discovery = self._discovered_device_advertisement_datas[address] - except KeyError: - # We expect this is the rare case and since py3.11+ has - # near zero cost try on success, and we can avoid .get() - # which is slower than [] we use the try/except pattern. - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) - else: - # Merge the new data with the old data - # to function the same as BlueZ which - # merges the dicts on PropertiesChanged - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.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: - service_uuids = list({*service_uuids, *prev_service_uuids}) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } + diag = await super().async_diagnostics() + diag["storage"] = self._storage.async_get_advertisement_history_as_dict( + self.source + ) + return diag diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec02..fa8efabcb1d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce047747a0c..777d0ebe317 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta import itertools import logging from typing import TYPE_CHECKING, Any, Final @@ -16,6 +15,8 @@ from bluetooth_adapters import ( AdapterDetails, BluetoothAdapters, ) +from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -26,13 +27,7 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -103,16 +98,12 @@ class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( - "hass", - "_integration_matcher", "_cancel_unavailable_tracking", - "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", - "_callback_index", "_bleak_callbacks", "_all_history", "_connectable_history", @@ -125,21 +116,17 @@ class BluetoothManager: "slot_manager", "_debug", "shutdown", + "_loop", ) def __init__( self, - hass: HomeAssistant, - integration_matcher: IntegrationMatcher, bluetooth_adapters: BluetoothAdapters, storage: BluetoothStorage, slot_manager: BleakSlotManager, ) -> None: """Init bluetooth manager.""" - self.hass = hass - self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None + self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals @@ -152,7 +139,6 @@ class BluetoothManager: str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} - self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] @@ -167,6 +153,7 @@ class BluetoothManager: self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False + self._loop: asyncio.AbstractEventLoop | None = None @property def supports_passive_scan(self) -> bool: @@ -209,7 +196,6 @@ class BluetoothManager: return adapter return None - @hass_callback def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: """Return the scanner for a source.""" return self._sources.get(source) @@ -232,45 +218,22 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) - @hass_callback - def _async_logging_changed(self, event: Event) -> None: - """Handle logging change.""" - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: """Set up the bluetooth manager.""" + self._loop = asyncio.get_running_loop() await self._bluetooth_adapters.refresh() install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) - self._async_trigger_matching_discovery(service_info) - @hass_callback - def async_stop(self, event: Event) -> None: + def async_stop(self) -> 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.cancel() self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() - @hass_callback def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: @@ -291,7 +254,6 @@ class BluetoothManager: ) ] - @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """Return all of discovered addresses. @@ -307,24 +269,25 @@ class BluetoothManager: for scanner in self._non_connectable_scanners ) - @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" histories = self._connectable_history if connectable else self._all_history return [history.device for history in histories.values()] - @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", + self._schedule_unavailable_tracking() + + def _schedule_unavailable_tracking(self) -> None: + """Schedule the unavailable tracking.""" + if TYPE_CHECKING: + assert self._loop is not None + loop = self._loop + self._cancel_unavailable_tracking = loop.call_at( + loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable ) - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: + def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" monotonic_now = MONOTONIC_TIME() connectable_history = self._connectable_history @@ -366,8 +329,7 @@ class BluetoothManager: # available for both connectable and non-connectable tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) + self._address_disappeared(address) service_info = history.pop(address) @@ -380,13 +342,13 @@ class BluetoothManager: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) + self._schedule_unavailable_tracking() + + def _address_disappeared(self, address: str) -> None: + """Call when an address disappears from the stack. + + This method is intended to be overridden by subclasses. + """ def _prefer_previous_adv_from_different_source( self, @@ -439,7 +401,6 @@ class BluetoothManager: return False return True - @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. @@ -570,16 +531,6 @@ class BluetoothManager: time=service_info.time, ) - matched_domains = self._integration_matcher.match_domains(service_info) - if self._debug: - _LOGGER.debug( - "%s: %s %s match: %s", - self._async_describe_source(service_info), - address, - service_info.advertisement, - matched_domains, - ) - if (connectable or old_connectable_service_info) and ( bleak_callbacks := self._bleak_callbacks ): @@ -589,22 +540,14 @@ class BluetoothManager: for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - for match in self._callback_index.match_callbacks(service_info): - callback = match[CALLBACK] - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") + self._discover_service_info(service_info) - for domain in matched_domains: - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + """Discover a new service info. + + This method is intended to be overridden by subclasses. + """ - @hass_callback def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: """Describe a source.""" if scanner := self._sources.get(service_info.source): @@ -615,7 +558,6 @@ class BluetoothManager: description += " [connectable]" return description - @hass_callback def async_track_unavailable( self, callback: Callable[[BluetoothServiceInfoBleak], None], @@ -629,7 +571,6 @@ class BluetoothManager: unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) - @hass_callback def _async_remove_callback() -> None: unavailable_callbacks[address].remove(callback) if not unavailable_callbacks[address]: @@ -637,50 +578,6 @@ class BluetoothManager: return _async_remove_callback - @hass_callback - def async_register_callback( - self, - callback: BluetoothCallback, - matcher: BluetoothCallbackMatcher | None, - ) -> Callable[[], None]: - """Register a callback.""" - callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) - if not matcher: - callback_matcher[CONNECTABLE] = True - else: - # We could write out every item in the typed dict here - # but that would be a bit inefficient and verbose. - callback_matcher.update(matcher) - callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) - - connectable = callback_matcher[CONNECTABLE] - self._callback_index.add_callback_matcher(callback_matcher) - - @hass_callback - def _async_remove_callback() -> None: - self._callback_index.remove_callback_matcher(callback_matcher) - - # If we have history for the subscriber, we can trigger the callback - # immediately with the last packet so the subscriber can see the - # device. - history = self._connectable_history if connectable else self._all_history - service_infos: Iterable[BluetoothServiceInfoBleak] = [] - if address := callback_matcher.get(ADDRESS): - if service_info := history.get(address): - service_infos = [service_info] - else: - service_infos = history.values() - - for service_info in service_infos: - if ble_device_matches(callback_matcher, service_info): - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") - - return _async_remove_callback - - @hass_callback def async_ble_device_from_address( self, address: str, connectable: bool ) -> BLEDevice | None: @@ -690,13 +587,11 @@ class BluetoothManager: return history.device return None - @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" histories = self._connectable_history if connectable else self._all_history return address in histories - @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: @@ -704,7 +599,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.values() - @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: @@ -712,28 +606,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.get(address) - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) - - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) - def async_register_scanner( self, scanner: BaseHaScanner, @@ -761,7 +633,6 @@ class BluetoothManager: self.slot_manager.register_adapter(scanner.adapter, connection_slots) return _unregister_scanner - @hass_callback def async_register_bleak_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: @@ -769,7 +640,6 @@ class BluetoothManager: callback_entry = (callback, filters) self._bleak_callbacks.append(callback_entry) - @hass_callback def _remove_callback() -> None: self._bleak_callbacks.remove(callback_entry) @@ -783,29 +653,180 @@ class BluetoothManager: return _remove_callback - @hass_callback def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) - @hass_callback def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) - @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" return self._intervals.get(address) - @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: """Get the fallback availability timeout for a MAC address.""" return self._fallback_intervals.get(address) - @hass_callback def async_set_fallback_availability_interval( self, address: str, interval: float ) -> None: """Override the fallback availability timeout for a MAC address.""" self._fallback_intervals[address] = interval + + +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" + + __slots__ = ( + "hass", + "_integration_matcher", + "_callback_index", + "_cancel_logging_listener", + ) + + def __init__( + self, + hass: HomeAssistant, + integration_matcher: IntegrationMatcher, + bluetooth_adapters: BluetoothAdapters, + storage: BluetoothStorage, + slot_manager: BleakSlotManager, + ) -> None: + """Init bluetooth manager.""" + self.hass = hass + self._integration_matcher = integration_matcher + self._callback_index = BluetoothCallbackMatcherIndex() + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, storage, slot_manager) + + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) + + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + matched_domains = self._integration_matcher.match_domains(service_info) + if self._debug: + _LOGGER.debug( + "%s: %s %s match: %s", + self._async_describe_source(service_info), + service_info.address, + service_info.advertisement, + matched_domains, + ) + + for match in self._callback_index.match_callbacks(service_info): + callback = match[CALLBACK] + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + for domain in matched_domains: + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) + + def async_register_callback( + self, + callback: BluetoothCallback, + matcher: BluetoothCallbackMatcher | None, + ) -> Callable[[], None]: + """Register a callback.""" + callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) + if not matcher: + callback_matcher[CONNECTABLE] = True + else: + # We could write out every item in the typed dict here + # but that would be a bit inefficient and verbose. + callback_matcher.update(matcher) + callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) + + connectable = callback_matcher[CONNECTABLE] + self._callback_index.add_callback_matcher(callback_matcher) + + def _async_remove_callback() -> None: + self._callback_index.remove_callback_matcher(callback_matcher) + + # If we have history for the subscriber, we can trigger the callback + # immediately with the last packet so the subscriber can see the + # device. + history = self._connectable_history if connectable else self._all_history + service_infos: Iterable[BluetoothServiceInfoBleak] = [] + if address := callback_matcher.get(ADDRESS): + if service_info := history.get(address): + service_infos = [service_info] + else: + service_infos = history.values() + + for service_info in service_infos: + if ble_device_matches(callback_matcher, service_info): + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + return _async_remove_callback + + @hass_callback + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b4975e61507..5d54ae6ea82 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,8 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.16.0", - "dbus-fast==2.14.0" + "bluetooth-data-tools==1.17.0", + "dbus-fast==2.20.0", + "habluetooth==0.10.0" ] } diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994..a35c5be6daf 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,15 +2,12 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Final -from bleak import BaseBleakClient +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: from .manager import BluetoothManager @@ -20,22 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - - -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - - PASSIVE = "passive" - ACTIVE = "active" - - BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..8da0d2c462b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 896d9dc7958..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,386 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from datetime import datetime -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from dbus_fast import InvalidMessageError - -from homeassistant.core import HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import MONOTONIC_TIME, BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> None: - """Set up the scanner.""" - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,10 +2,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 9de020f163e..e3c08a035a8 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback ), timeout=self.__timeout, - hass=manager.hass, ) if debug_logging: # Only lookup the description if we are going to log it 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/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 3cb81ba40b4..465c4b8966b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity 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": { diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0..f76ed5939f5 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge 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/components/deconz/light.py b/homeassistant/components/deconz/light.py index dc2ed04b4ed..044c9bf203b 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } -TS0601_EFFECTS = [ +XMAS_LIGHT_EFFECTS = [ "carnival", "collide", "fading", @@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] - if device.model_id == "TS0601": - self._attr_effect_list += TS0601_EFFECTS + if device.model_id in ("HG06467", "TS0601"): + self._attr_effect_list = XMAS_LIGHT_EFFECTS @property def color_mode(self) -> str | None: diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..a17972526cf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ 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_schema_error, load_yaml_config_file +from homeassistant.config import ( + async_log_schema_error, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, @@ -284,7 +287,7 @@ class DeviceTrackerPlatform: ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: @@ -1033,6 +1036,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/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index ed878fbb82e..9648492c2e4 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -183,6 +183,7 @@ async def async_setup_entry( for description in sensors for value_key in {description.key, *description.alternative_keys} if description.value_fn(coordinator.data, value_key, description.scale) + is not None ) async_add_entities(entities) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766..86a7bee9ef1 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -116,7 +116,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ec0623a9ed6..4ac59372deb 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,8 +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" CONF_SERIAL_ID = "serial_id" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0fa04dee489..6aadcd63d44 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -46,9 +46,7 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -80,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", translation_key="current_electricity_usage", @@ -647,11 +652,9 @@ 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): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") @@ -663,9 +666,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 @@ -795,9 +796,7 @@ class DSMREntity(SensorEntity): return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), 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: diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6fa177c7221..6f57ea6ed5f 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==1.0.0"] + "requirements": ["easyenergy==2.1.0"] } diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 9ef99173ffb..025f929a4f6 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==1.0.0"] + "requirements": ["energyzero==2.1.0"] } diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 96f1bce686a..06282749649 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from functools import partial import logging import sys -from typing import Any, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import uuid if sys.version_info < (3, 12): @@ -60,7 +60,9 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) +_ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient") +_R = TypeVar("_R") +_P = ParamSpec("_P") def mac_to_int(address: str) -> int: @@ -68,12 +70,14 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: +def api_error_as_bleak_error( + func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: + self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: # pylint: disable=protected-access try: return await func(self, *args, **kwargs) @@ -107,7 +111,7 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: except APIConnectionError as err: raise BleakError(str(err)) from err - return cast(_WrapFuncType, _async_wrap_bluetooth_operation) + return _async_wrap_bluetooth_operation @dataclass(slots=True) diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index a54e7af59a6..b4fb12210d3 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -7,11 +7,14 @@ from bluetooth_data_tools import ( parse_advertisement_data_tuple, ) -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback -class ESPHomeScanner(BaseHaRemoteScanner): +class ESPHomeScanner(HomeAssistantRemoteScanner): """Scanner for esphome.""" __slots__ = () diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 9942498e12d..08135e1a702 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index db9cd9ba72c..e0b47f09d95 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,8 +15,8 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.16.0", + "aioesphomeapi==19.3.0", + "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 769c8e597cd..062bba1cfdc 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.9"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6..233388d5013 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ class FirmataBoard: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c..f5b7cb5af40 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d31ccd180c4..8dc51e59738 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -38,11 +38,9 @@ async def async_setup_entry( FritzboxLight( coordinator, ain, - device.get_colors(), - device.get_color_temps(), ) for ain in coordinator.new_devices - if (device := coordinator.data.devices[ain]).has_lightbulb + if (coordinator.data.devices[ain]).has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -57,27 +55,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): self, coordinator: FritzboxDataUpdateCoordinator, ain: str, - supported_colors: dict, - supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup self._supported_hs: dict[int, list[int]] = {} - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] @property def is_on(self) -> bool: @@ -173,3 +154,28 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """Get light attributes from device after entity is added to hass.""" + await super().async_added_to_hass() + supported_colors = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_colors + ) + supported_color_temps = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_color_temps + ) + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b6668383b54..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==20231130.0"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/components/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000..5845e00f8b0 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000..463f0724919 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} 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. diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index ba162173724..8d50cdf2aed 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,10 +38,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( @@ -52,6 +50,7 @@ from .const import ( FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -105,7 +104,7 @@ async def async_setup_entry( ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -120,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90d..c965ad45721 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ from .const import DOMAIN class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7..45911433b92 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7916df18abc..3c1893f7735 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -33,10 +33,6 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -66,33 +62,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -134,7 +130,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: 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: diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 751e9005809..0c0fe55b686 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -67,18 +67,20 @@ class HassIOIngress(HomeAssistantView): self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -128,7 +130,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000..224b1b01294 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + 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/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000..bb9a332cb73 --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000..1ba4a2a0c26 --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.data = user_input + + selected_country = user_input[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + locale = Locale(self.hass.config.language) + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=user_input) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + locale = Locale(self.hass.config.language) + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000..5d2a567a488 --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000..f73577bddee --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.37", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000..4762a48c659 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 16a7ee5009c..926ab5025f6 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = { "scene", "script", "switch", + "todo", "vacuum", "water_heater", } diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 3308083f22f..9abfefc996f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -30,11 +30,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.service import ( async_extract_entity_ids, @@ -208,7 +204,7 @@ async def async_setup_platform( await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55..84aafb44808 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 036f6c077da..35b303a62e3 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,61 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old entry. + + The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does. + But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT. + + This migration migrates the old unique_id to the new one, if possible. + + Migration can be removed after 2024.6 + """ + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + replacements = { + "total_power_import_t1_kwh": "total_power_import_kwh", + "total_power_export_t1_kwh": "total_power_export_kwh", + } + + for old_id, new_id in replacements.items(): + if entry.unique_id.endswith(old_id): + new_unique_id = entry.unique_id.replace(old_id, new_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" coordinator = Coordinator(hass) @@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise + await _async_migrate_entries(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Abort reauth config flow if active diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ff065592283..d4692ee8bf0 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from homewizard_energy.models import Data, Device, State, System @@ -11,6 +12,8 @@ from homeassistant.const import Platform DOMAIN = "homewizard" PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +LOGGER = logging.getLogger(__package__) + # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 58e0b02a06c..ced870d7072 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 78cee9ee6fe..d980e66e0e4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -436,7 +436,6 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( HomeWizardSensorEntity(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d8c939e5c3a..dcd40b8346c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -135,6 +135,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000..2f4b7274fc0 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,132 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +@dataclass +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + key: str + item: str + + _raw_state: str | None = field(default=None, init=False) + + def __post_init__(self) -> None: + """Initialize remaining attributes.""" + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 754f192e57e..225146799a3 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -286,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "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" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" 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": { diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a8efb663c90..b30a9b375b0 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction" ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity): departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity): for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1..de6091e3638 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b4..ebdef4146e0 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65..a22a16a6e69 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f..7d03ed2efaf 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b92..db265449f37 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index a90b5a71c2d..398fcb95872 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.16.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ca46565b773..440ec427f8d 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.16.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "led-ble==1.0.1"] } 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/lock/__init__.py b/homeassistant/components/lock/__init__.py index ed7e2070055..ca91236a77c 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -33,7 +33,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) @@ -75,48 +74,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) return True -@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}" - ) - 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.""" - await entity.async_unlock(**_add_default_code(entity, service_call)) - - -async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: - """Open the door latch.""" - await entity.async_open(**_add_default_code(entity, service_call)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -149,6 +121,21 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + raise ValueError( + f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}" + ) + if code: + data[ATTR_CODE] = code + return data + @property def changed_by(self) -> str | None: """Last change triggered by.""" @@ -193,6 +180,11 @@ class LockEntity(Entity): """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -201,6 +193,11 @@ class LockEntity(Entity): """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -209,6 +206,11 @@ class LockEntity(Entity): """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e1641451221..d935ad9bff5 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None 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": { 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) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164..623d0f06295 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index ddda50aa8b2..44a65a2de59 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__) SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ class MatrixBot: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { 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/lock.py b/homeassistant/components/matter/lock.py index 8491f58e387..dd29638f765 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) 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/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5..d1ed5cafcbf 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a..b19e268a4c3 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,6 +61,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + entry: config_entries.ConfigEntry | None = None + async def _create_entry(self, username: str, token: str): """Register new entry.""" await self.async_set_unique_id(username) @@ -126,3 +133,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + 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: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + 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_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d..3abb30bf9ac 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 3b6bb9c3518..567788ec479 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.2.0"] + "requirements": ["meteofrance-api==1.3.0"] } 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 diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee..74a1de48c0a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -100,7 +112,6 @@ from .const import ( # noqa: F401 CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -123,6 +134,7 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -179,9 +191,10 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, @@ -265,6 +278,26 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e..1c7c8f65140 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -55,13 +56,13 @@ from .const import ( CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -75,8 +76,34 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -93,8 +120,6 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -154,12 +179,10 @@ class BasePlatform(Entity): class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] @@ -250,10 +273,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -346,15 +369,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae8931..6c0f6422df2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070..76132014413 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ import struct from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -67,7 +89,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +101,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -137,6 +160,42 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -193,6 +252,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -254,7 +328,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -268,7 +342,17 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: @@ -288,15 +372,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc97..e536a31c4f6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,13 +45,23 @@ CONF_STEP = "temp_step" CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -60,6 +70,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc18..072f1bb3d93 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b..e5006b66f81 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21..acc01f39b46 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6..c015d117b13 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df596..c549b59bf8f 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,13 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb84096006..0c955ea409d 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 52919a24ac7..7dc5a91a2fa 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,13 +26,16 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -115,8 +118,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) 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 + swap_type = config.get(CONF_SWAP) for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), @@ -136,9 +139,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: ) raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: + if swap_type: swap_type_validator = { - 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, @@ -266,12 +268,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -282,7 +303,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -301,11 +322,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -315,3 +336,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c32235..e6bcff715b8 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, 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/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e3dcf66c8b1..24783e171c8 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3d2957f153d..8702069eab7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,10 +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( - 255, - round(brightness * 255 / scale), # type: ignore[operator] + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -591,13 +591,12 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: 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." } } }, 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/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3..7c78bfc44d3 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,17 +6,18 @@ from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -125,7 +126,7 @@ def async_setup_legacy( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None @@ -279,8 +280,8 @@ class BaseNotificationService: # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: 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": { 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." } } }, diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adf..106103cf752 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 1022ab07e2c..d3a5928150e 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.39"] + "requirements": ["opower==0.0.40"] } diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + 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) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "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%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +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 . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 8115066d0fb..5b8d19e5aa1 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -89,7 +89,7 @@ class OurGroceriesTodoListEntity( if item.summary: api_items = self.coordinator.data[self._list_id]["list"]["items"] category = next( - api_item["categoryId"] + api_item.get("categoryId") for api_item in api_items if api_item["id"] == item.uid ) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ebc3f96a7f5..03a81f67308 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,10 +1,8 @@ """The Overkiz (by Somfy) integration.""" 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 @@ -16,7 +14,7 @@ from pyoverkiz.exceptions import ( NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry @@ -82,13 +80,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.login() + setup = await client.get_setup() - setup, scenarios = await asyncio.gather( - *[ - client.get_setup(), - client.get_scenarios(), - ] - ) + # Local API does expose scenarios, but they are not functional. + # Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21 + if api_type == APIType.CLOUD: + scenarios = await client.get_scenarios() + else: + scenarios = [] except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: @@ -98,9 +97,6 @@ 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/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..a267b54b398 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, @@ -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 diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 2a549f1c24d..a756df4d0d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -9,7 +9,7 @@ } }, "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.", + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.", "data": { "api_type": "API type" } diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 261cdb031bf..144495ec066 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -33,7 +33,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - meter_verification: bool = False meter_data: dict[str, str] = {} meter_error: dict[str, str] = {} @@ -53,17 +52,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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", @@ -86,20 +78,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - 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)) + await self._verify_meter(phone_number) self.meter_data = user_input - return self.async_show_progress( - step_id="user", - progress_action="verifying_meter", - ) + return await self.async_step_finish_smart_meter() async def async_step_finish_smart_meter( self, user_input: dict[str, Any] | None = None @@ -107,7 +94,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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, diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index dadd105b606..f6bda9693b8 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(minutes=5), + update_interval=timedelta(seconds=30), ) async def _async_update_data(self) -> PingResult: 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/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2..6dbd6118d7c 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.6", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index d894b18f545..ed7fc975d9e 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.16.0"] + "requirements": ["bluetooth-data-tools==1.17.0"] } diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06..098603b9494 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def discover_scripts(hass): # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) 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/const.py b/homeassistant/components/renson/const.py index 840e1ce428a..53bbd90c4b7 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6..a60adccade5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,16 +7,19 @@ from typing import Any from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator @@ -41,6 +44,33 @@ SPEED_MAPPING = { Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ class RensonFan(RensonEntity, FanEntity): await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a946..fa94207748e 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000..ad79af8649e --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..a826b5a3dd3 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." } } }, @@ -159,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 584b380f391..8da64991c27 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -24,6 +24,7 @@ _T = TypeVar("_T") class ReolinkChannelEntityDescription(EntityDescription): """A class that describes entities for a camera channel.""" + cmd_key: str | None = None supported: Callable[[Host, int], bool] = lambda api, ch: True @@ -31,6 +32,7 @@ class ReolinkChannelEntityDescription(EntityDescription): class ReolinkHostEntityDescription(EntityDescription): """A class that describes host entities.""" + cmd_key: str | None = None supported: Callable[[Host], bool] = lambda api: True @@ -84,6 +86,15 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..dfc77806932 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -60,6 +60,8 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -163,7 +165,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() @@ -311,7 +313,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index b2d0402b1b9..8df69b156ad 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -41,6 +41,7 @@ class ReolinkLightEntityDescription( LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", supported=lambda api, ch: api.supported(ch, "floodLight"), @@ -51,6 +52,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", + cmd_key="GetIrLights", translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, @@ -60,6 +62,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ffbc2fb186..e03fa28b7ce 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.1"] + "requirements": ["reolink-aio==0.8.2"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6a89eabba2b..aaf549453ed 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -41,6 +41,7 @@ class ReolinkNumberEntityDescription( NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -53,6 +54,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -68,6 +70,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -80,6 +83,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -92,6 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -105,6 +110,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -117,6 +123,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -131,6 +138,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -145,6 +153,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -159,6 +168,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -175,6 +185,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_animal_sensititvity", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -189,6 +200,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -205,6 +217,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -221,6 +234,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -237,6 +251,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -255,6 +270,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_animal_delay", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -271,6 +287,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -284,6 +301,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -297,6 +315,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -310,6 +329,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -325,6 +345,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -338,6 +359,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="day_night_switch_threshold", + cmd_key="GetIsp", translation_key="day_night_switch_threshold", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 3d75b08b5d1..eb2ea58cc40 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -44,6 +44,7 @@ class ReolinkSelectEntityDescription( SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -54,6 +55,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -72,6 +74,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -84,6 +87,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -94,6 +98,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 3a5da97dc61..5eef880fc91 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -52,6 +52,7 @@ class ReolinkHostSensorEntityDescription( SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -64,6 +65,7 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, 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/reolink/switch.py b/homeassistant/components/reolink/switch.py index fbb8922188d..352ba7a1103 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -50,6 +50,7 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -59,6 +60,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -68,6 +70,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -77,6 +80,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -86,6 +90,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -95,6 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -104,6 +110,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -113,6 +120,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -122,6 +130,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -131,6 +140,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -140,6 +150,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -149,6 +160,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -162,6 +174,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -171,6 +184,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -180,6 +194,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -189,6 +204,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -198,6 +214,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 54a60d34229..12b9290af99 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -643,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: 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/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e..acaf2e5adbc 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( 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/roon/media_player.py b/homeassistant/components/roon/media_player.py index d6128d26723..dda323c2c2a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -373,14 +373,14 @@ class RoonDevice(MediaPlayerEntity): def volume_up(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, -3) 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." } } }, diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0..8a154bca019 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -10,7 +10,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, - BaseHaRemoteScanner, + HomeAssistantRemoteScanner, async_get_advertisement_callback, async_register_scanner, ) @@ -22,7 +22,7 @@ from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class RuuviGatewayScanner(BaseHaRemoteScanner): +class RuuviGatewayScanner(HomeAssistantRemoteScanner): """Scanner for Ruuvi Gateway.""" def __init__( diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000..ae3b8d58f5e --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000..11fce2c4b47 --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498..d78c2c16e48 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -150,13 +150,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +169,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +187,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f49927171..f16f2b9ff34 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6..cda1bc83f81 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ ENTITY_MIGRATIONS = { "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3..253d16610e4 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -170,8 +174,10 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): await self._async_set_circuit(ON_OFF.OFF) async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700..434b8921bc2 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.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72b..091d377a56b 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,6 +16,7 @@ from homeassistant.components.number 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 .const import DOMAIN as SL_DOMAIN @@ -32,7 +34,6 @@ class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] @dataclass @@ -47,20 +48,12 @@ class ScreenLogicNumberDescription( SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -113,7 +106,6 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -138,21 +130,14 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) - # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self._set_value_func(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014..5d4efc55883 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -139,7 +139,7 @@ SUPPORTED_INTELLICHEM_SENSORS = [ ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294f..1cbab23d843 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ from homeassistant.components.blueprint import ( is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10..069037e53a0 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd412..b0313d227a3 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 7c0dc3c792a..3ada1ce55f5 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,13 +6,16 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback from ..const import LOGGER -class ShellyBLEScanner(BaseHaRemoteScanner): +class ShellyBLEScanner(HomeAssistantRemoteScanner): """Scanner for shelly.""" @callback diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 16558d2c795..b97ca06a471 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -497,14 +497,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Return the unit of measurement.""" return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] - def _determine_swing_modes(self) -> list[str]: + def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" + supported_swings = None 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 - ] + if supported_modes is not None: + 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: diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909..6c814b781b2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105..d3ba407fa40 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..87600650551 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 @@ -31,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 @@ -193,10 +195,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 +248,34 @@ 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 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, + }, + ) with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +305,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 +331,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/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..37250fa6447 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}\" from 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/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": { diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b..756235ae247 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/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 Logitech Media Server." } }, "edit": { diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae..4a974077592 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,19 +41,19 @@ def setup_platform( class StreamlabsLocationData: """Track and query location data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the location data.""" self._location_id = location_id self._client = client self._is_away = None @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): + def update(self) -> None: """Query and store location data.""" location = self._client.get_location(self._location_id) self._is_away = location["homeAway"] == "away" - def is_away(self): + def is_away(self) -> bool | None: """Return whether away more is enabled.""" return self._is_away @@ -59,19 +61,21 @@ class StreamlabsLocationData: class StreamlabsAwayMode(BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): + def __init__( + self, location_name: str, streamlabs_location_data: StreamlabsLocationData + ) -> None: """Initialize the away mode device.""" self._location_name = location_name self._streamlabs_location_data = streamlabs_location_data self._is_away = None @property - def name(self): + def name(self) -> str: """Return the name for away mode.""" return f"{self._location_name} {NAME_AWAY_MODE}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return if away mode is on.""" return self._streamlabs_location_data.is_away() diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f..42e551c5c11 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant @@ -48,7 +50,7 @@ def setup_platform( class StreamlabsUsageData: """Track and query usage data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the usage data.""" self._location_id = location_id self._client = client @@ -57,22 +59,22 @@ class StreamlabsUsageData: self._this_year = None @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): + def update(self) -> None: """Query and store usage data.""" water_usage = self._client.get_water_usage_summary(self._location_id) self._today = round(water_usage["today"], 1) self._this_month = round(water_usage["thisMonth"], 1) self._this_year = round(water_usage["thisYear"], 1) - def get_daily_usage(self): + def get_daily_usage(self) -> float | None: """Return the day's usage.""" return self._today - def get_monthly_usage(self): + def get_monthly_usage(self) -> float | None: """Return the month's usage.""" return self._this_month - def get_yearly_usage(self): + def get_yearly_usage(self) -> float | None: """Return the year's usage.""" return self._this_year @@ -83,7 +85,9 @@ class StreamLabsDailyUsage(SensorEntity): _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__(self, location_name, streamlabs_usage_data): + def __init__( + self, location_name: str, streamlabs_usage_data: StreamlabsUsageData + ) -> None: """Initialize the daily water usage device.""" self._location_name = location_name self._streamlabs_usage_data = streamlabs_usage_data @@ -95,7 +99,7 @@ class StreamLabsDailyUsage(SensorEntity): return f"{self._location_name} {NAME_DAILY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @@ -113,7 +117,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -127,6 +131,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d..cd5aef312ce 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,8 +6,9 @@ from collections.abc import AsyncIterable, Coroutine import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e..858bda35c0f 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/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 SwitchBee device." } } }, diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238..4ed06119577 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,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": "The hostname or IP address of your Synology NAS." } }, "2sa": { diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7d150e95977..1d71e055e2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -991,6 +992,7 @@ class BaseTelegramBotEntity: event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args 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%]" } } }, 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/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/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 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( { 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/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/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875..9a44382e851 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ class SpeechManager: self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d1..05be2e284e3 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,12 +26,12 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -103,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( 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": { 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, 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/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..4d5cf49b5c9 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", @@ -231,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, 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/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ee6f6d05548..045538aa2d1 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.21.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", 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..bf19fe5188e 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 of 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/vesync/fan.py b/homeassistant/components/vesync/fan.py index 22983054dc9..f0d4d02a9a3 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,10 +11,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 76de3a8a7ac..2e3284c37c4 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -41,7 +41,7 @@ _TOKEN_FILENAME = "vicare_token.save" class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] @dataclass() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 525099e7d4e..95a4bcdc9f0 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,7 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -40,6 +41,8 @@ class ViCareBinarySensorEntityDescription( ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 5511f2a5294..965b5a619fc 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -19,7 +19,11 @@ from PyViCare.PyViCareUtils import ( ) from requests.exceptions import ConnectionError as RequestConnectionError -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -37,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" + value_getter: Callable[[PyViCareDevice], float] 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 @@ -49,6 +54,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( @@ -77,6 +83,42 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=3.5, native_step=0.1, ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), ) @@ -149,6 +191,7 @@ 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 ): diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 47ee60b2ea8..6c08215a9c1 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,9 +80,6 @@ }, "comfort_temperature": { "name": "Comfort temperature" - }, - "eco_temperature": { - "name": "Eco temperature" } }, "sensor": { 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": { diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000..bd6571a390e --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,175 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import check_absolute_change + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not _check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not _check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False 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/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850..824c85781ea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a6dd40d5993 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.2"] } 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/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196..39abdba6e82 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY 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/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9cc96db7a57..2d1030c6b92 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -209,21 +209,26 @@ class IsWorkdaySensor(BinarySensorEntity): async def async_update(self) -> None: """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(dt_util.now()) + + async def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" # Default is no workday - self._attr_is_on = False + is_workday = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt_util.now() + timedelta(days=self._days_offset) + adjusted_date = check_date + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): - self._attr_is_on = True + is_workday = True if self.is_exclude(day_of_week, adjusted_date): - self._attr_is_on = False + is_workday = 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} + return is_workday diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7c993e70d0..dd2df87234f 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.36"] + "requirements": ["holidays==0.37"] } diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..88e490d6dc9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,31 @@ 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, + Platform.NUMBER, +] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +39,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..bd7252bcf6b --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,141 @@ +"""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 + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + _audio_settings_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_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_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 + + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_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" + ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) 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..7174683fd18 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.4.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/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000..5e769eeb06d --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..94f61c17047 --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,404 @@ +"""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.error import Error +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) + self.device.set_audio_settings_listener(self._audio_settings_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) + + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # 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, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), + device_id=self.device.device_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)) + elif event.type == assist_pipeline.PipelineEventType.ERROR: + # Pipeline error + if event.data: + self.hass.add_job( + self._client.write_event( + Error( + text=event.data["message"], code=event.data["code"] + ).event() + ) + ) + + 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..c04bad4bef8 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,94 @@ +"""Select entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +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 + +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming select entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(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) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..7b6be68aeb2 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,46 @@ }, "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_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } 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/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/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7322c58ae04..a42687a3551 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.1"] + "requirements": ["yolink-api==0.3.4"] } 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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5eb77b0c41c..8351212f0b8 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.127.0"] + "requirements": ["zeroconf==0.128.0"] } 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." } } }, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4bdedebfff9..87f59f31e9b 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -253,7 +253,7 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_ids and quirk_id: + if self.quirk_ids: if callable(self.quirk_ids): matches.append(self.quirk_ids(quirk_id)) else: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c6b9a104885..7364aed0d1b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -20,10 +20,10 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions 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/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 9e50b55830c..7f4855bfbe5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ from .helpers import ( get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7..65c77f8ab2d 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema] +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: diff --git a/homeassistant/config.py b/homeassistant/config.py index b4850e372fd..95dd42737a0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass from enum import StrEnum @@ -48,6 +48,7 @@ from .const import ( CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -58,19 +59,14 @@ from .const import ( from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES -from .helpers import ( - config_per_platform, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir 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 .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 +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -145,7 +141,7 @@ class ConfigExceptionInfo: exception: Exception translation_key: ConfigErrorTranslationKey - platform_name: str + platform_path: str config: ConfigType integration_link: str | None @@ -453,6 +449,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -467,15 +476,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): @@ -663,7 +672,14 @@ 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}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: message_suffix = f", please check the docs at {link}" else: @@ -734,7 +750,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}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + 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]): @@ -972,9 +995,13 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( @@ -1068,7 +1095,7 @@ def _get_log_message_and_stack_print_pref( ) -> 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_path = platform_exception.platform_path platform_config = platform_exception.config link = platform_exception.integration_link @@ -1092,7 +1119,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( - f"Unknown error validating {platform_name} platform config with {domain} " + f"Unknown error validating {platform_path} platform config with {domain} " "component platform schema", True, ), @@ -1105,7 +1132,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( - f"Unknown error validating config for {platform_name} platform " + f"Unknown error validating config for {platform_path} platform " f"for {domain} component with PLATFORM_SCHEMA", True, ), @@ -1119,7 +1146,7 @@ def _get_log_message_and_stack_print_pref( show_stack_trace = False if isinstance(exception, vol.Invalid): log_message = format_schema_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): placeholders["config_file"], line = annotation @@ -1128,9 +1155,9 @@ def _get_log_message_and_stack_print_pref( if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) - if annotation := find_annotation(platform_config, [platform_name]): + if annotation := find_annotation(platform_config, [platform_path]): placeholders["config_file"], line = annotation placeholders["line"] = str(line) show_stack_trace = True @@ -1222,6 +1249,46 @@ def async_handle_component_errors( ) +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None + + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs + + async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, @@ -1332,7 +1399,7 @@ async def async_process_component_config( # noqa: C901 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}" + platform_path = f"{p_name}.{domain}" try: p_validated = component_platform_schema(p_config) except vol.Invalid as exc: @@ -1369,7 +1436,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1382,7 +1449,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1397,7 +1464,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, - platform_name, + platform_path, p_config, p_integration.documentation, ) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7d9d8d19b49..7f0883ca880 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -107,9 +108,10 @@ if TYPE_CHECKING: from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() @@ -299,6 +301,14 @@ class HassJob(Generic[_P, _R_co]): return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -370,6 +380,7 @@ class HomeAssistant: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -766,6 +777,42 @@ class HomeAssistant: for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -799,6 +846,26 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -816,33 +883,32 @@ class HomeAssistant: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -856,12 +922,12 @@ class HomeAssistant: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -870,11 +936,11 @@ class HomeAssistant: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task ) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( - "Task %s error during stage 3 shutdown: %s", task, exc + "Task %s error during final shutdown stage: %s", task, exc ) # Prevent run_callback_threadsafe from scheduling any additional @@ -885,14 +951,14 @@ class HomeAssistant: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -912,10 +978,10 @@ class HomeAssistant: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6def4498055..2dc743eaf1c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -202,6 +202,7 @@ FLOWS = { "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", @@ -348,6 +349,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a59d6387c8c..bfe5b0ae331 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1947,6 +1947,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", @@ -2414,6 +2419,12 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "name": "Holiday", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", @@ -4145,6 +4156,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", @@ -4948,6 +4965,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", 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", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 036c50ea445..0aae4a943dd 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -720,6 +720,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712..52197e83495 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config - if not isinstance(platform_config, list): - platform_config = [platform_config] + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - yield platform, item + +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 23707949dcd..1c8efadfdc5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,6 +31,7 @@ from homeassistant.requirements import ( ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901 core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: @@ -276,13 +277,17 @@ async def async_check_ha_config_file( # noqa: C901 # 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"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba0789..e4b62dd679d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -350,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any( ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) @@ -581,7 +606,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..20dbacde480 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -97,9 +97,13 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> 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,9 @@ 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", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -116,9 +122,13 @@ def deprecated_class( def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> 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 +136,9 @@ 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", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -134,10 +146,29 @@ 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, + breaks_in_ha_version: str | None, +) -> None: logger = logging.getLogger(obj.__module__) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s%s. Use %s instead", + obj.__name__, + description, + breaks_in, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,27 +180,24 @@ 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%s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) 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%s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, + breaks_in, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 775d0934c36..30e892a8840 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -148,7 +148,7 @@ class EntityComponent(Generic[_EntityT]): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2fc82567739..be087241287 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -304,7 +304,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 42ebc2d0869..983b4e2da52 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -69,7 +68,7 @@ async def _resetup_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, platform_domain): + for p_type, p_config in conf_util.config_per_platform(conf, platform_domain): if p_type != integration_domain: continue diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7..2ada25bd4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ from homeassistant.exceptions import ( UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -542,7 +542,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878..d6a31085cfb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -48,7 +48,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +57,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -96,7 +96,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +154,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +225,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a219df6fd98..1874807b892 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,19 +15,20 @@ 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.16.0 +bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.14.0 +dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 +habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231130.0 -home-assistant-intents==2023.11.29 +home-assistant-frontend==20231206.0 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 @@ -56,8 +57,8 @@ ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 -zeroconf==0.127.0 +yarl==1.9.4 +zeroconf==0.128.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0e00d0b75f2..dcccdbccf40 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 679042bc4e9..7a7f4323be6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -263,7 +263,7 @@ async def _async_setup_component( 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 + hass, platform_exception.platform_path, platform_exception.integration_link ) if processed_config is None: log_error("Invalid config.") @@ -538,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -563,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 4520a62a5d8..0ab4ac8c6c1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ from typing import NamedTuple import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -744,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d14..4859c5c85dd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,9 +5,7 @@ import bisect from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time from typing import Any import zoneinfo @@ -16,7 +14,6 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -476,29 +473,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670..cc4835022d3 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000..70e2ac2516a --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43..fe4f01677cd 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ __all__ = [ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..60e917a6a99 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 @@ -36,6 +36,10 @@ _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -137,10 +141,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,15 +181,41 @@ 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 -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: +def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -169,6 +225,20 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -203,12 +273,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -257,7 +322,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -287,7 +355,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -312,9 +383,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] diff --git a/mypy.ini b/mypy.ini index ad0b4527930..b6976dac76d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1291,6 +1291,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +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.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true @@ -2932,6 +2942,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 844fac7142f..d7764864168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index aa9a0ab0e5a..1b5b8d63c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,4 +32,4 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index ef409176c61..392275cee1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -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 @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -504,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -526,7 +529,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 @@ -564,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.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 @@ -655,7 +658,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -723,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -756,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -789,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.9 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -980,6 +983,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.10.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 @@ -1013,14 +1019,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1237,7 +1244,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1416,7 +1423,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1951,6 +1958,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -2187,7 +2197,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -2251,7 +2261,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2341,10 +2351,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2407,7 +2417,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2597,7 +2607,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 @@ -2756,7 +2766,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2798,7 +2808,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2816,7 +2826,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4b115b2ab8..3b5d1e7c20e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -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 @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -438,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -448,7 +451,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 @@ -476,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.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 @@ -536,7 +539,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -589,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 @@ -610,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -779,6 +782,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.10.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 @@ -800,14 +806,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -961,7 +968,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1092,7 +1099,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1474,6 +1481,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1635,7 +1645,7 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -1687,7 +1697,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1753,10 +1763,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.rflink rflink==0.0.65 @@ -1795,7 +1805,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 @@ -1934,7 +1944,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 @@ -2060,7 +2070,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2096,7 +2106,7 @@ yalexs==1.10.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2111,7 +2121,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 3bd44736038..c9d81424229 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -59,9 +59,10 @@ WORKDIR /config def _generate_dockerfile() -> str: timeout = ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 4a826f7cad9..580294705cf 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296..caef6c2e729 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378c..bb9e6380cdc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -18,33 +15,35 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert 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_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -54,20 +53,48 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert 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_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -77,14 +104,41 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b..9e9b48a07f6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/common.py b/tests/common.py index b2fa53d28fb..15498019b16 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1427,7 +1427,7 @@ ANY = _HA_ANY() def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 24a4a92536d..882d3a80fb3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,4 +1,5 @@ """Test Voice Assistant init.""" +import asyncio from dataclasses import asdict import itertools as it from pathlib import Path @@ -569,6 +570,69 @@ async def test_pipeline_saved_audio_write_error( ) +async def test_pipeline_saved_audio_empty_queue( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's an empty queue.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + # Force timeout in _pipeline_debug_recording_thread_proc + await asyncio.sleep(1) + yield b"not used" + + # Wrap original function to time out immediately + _pipeline_debug_recording_thread_proc = ( + assist_pipeline.pipeline._pipeline_debug_recording_thread_proc + ) + + def proc_wrapper(run_recording_dir, queue): + _pipeline_debug_recording_thread_proc( + run_recording_dir, queue, message_timeout=0 + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline._pipeline_debug_recording_thread_proc", + proc_wrapper, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + async def test_wake_word_detection_aborted( hass: HomeAssistant, mock_stt_provider: MockSttProvider, diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 0f29c84c820..72cbc37d571 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -14,9 +14,9 @@ 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 = 60000000000, 50000000000 MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 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()) diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 61521c49b79..a78682ced6d 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -102,6 +104,8 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) # 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.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, @@ -133,7 +137,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraTimeoutError("No response after 3 tries"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -145,7 +149,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -159,7 +163,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraError("No response after 10 seconds"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -174,6 +178,8 @@ 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( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a33ca702bcf..4088b1819fa 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d..359303c51fd 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1102,7 +1102,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c..5261e7371f3 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager from homeassistant.components.bluetooth import ( DOMAIN, @@ -19,8 +20,6 @@ from homeassistant.components.bluetooth import ( async_get_advertisement_callback, models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +36,7 @@ __all__ = ( "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +56,22 @@ BLE_DEVICE_DEFAULTS = { } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=mock_time, + ), patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f0..8681287baa2 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +11,6 @@ from homeassistant.components.bluetooth import ( async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, @@ -352,7 +350,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") + scanner = FakeScanner("new", "fake_adapter") cancel_scanner = async_register_scanner(hass, scanner, False) @callback diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487..30e9554f2af 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_scanner_by_source, async_scanner_devices_by_address, ) @@ -27,7 +27,7 @@ from . import ( async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) assert async_scanner_by_source(hass, "hci2") is hci2_scanner @@ -46,7 +46,7 @@ async def test_async_scanner_devices_by_address_connectable( """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() - class FakeInjectableScanner(BaseHaRemoteScanner): + class FakeInjectableScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -135,7 +135,7 @@ async def test_async_scanner_devices_by_address_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) + scanner = FakeStaticScanner("esp32", "esp32", connector) cancel = manager.async_register_scanner(scanner, False) assert scanner.discovered_devices_and_advertisement_data == { diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d..2e2be0e7963 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,18 +8,16 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -37,11 +35,35 @@ from . import ( _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(HomeAssistantRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -89,23 +111,6 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -173,23 +178,6 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -214,10 +202,7 @@ async def test_remote_scanner_expires_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -248,23 +233,6 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -297,10 +265,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -313,10 +278,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -346,23 +308,6 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -418,7 +363,7 @@ async def test_restore_history_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -434,7 +379,7 @@ async def test_restore_history_remote_adapter( cancel() unsetup() - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -470,23 +415,6 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -514,11 +442,8 @@ async def test_device_with_ten_minute_advertising_interval( connectable=False, ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -527,11 +452,10 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -545,10 +469,7 @@ async def test_device_with_ten_minute_advertising_interval( bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -558,13 +479,7 @@ async def test_device_with_ten_minute_advertising_interval( future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -592,25 +507,6 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -628,10 +524,7 @@ async def test_scanner_stops_responding( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -652,11 +545,10 @@ async def test_scanner_stops_responding( failure_reached_time += 1 - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f06..a69c26a16ea 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,12 +3,13 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -442,7 +423,7 @@ async def test_diagnostics_remote_adapter( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -497,17 +478,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +497,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -596,19 +572,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "connectable": False, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +630,17 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "storage": None, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f5..63ff735ca43 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner import pytest from homeassistant.components import bluetooth @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -2815,7 +2815,7 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2823,7 +2823,7 @@ async def test_scanner_count_connectable( async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471..361f0cd008f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,12 +12,12 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_ble_device_from_address, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -56,7 +56,7 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") + hci0_scanner = FakeScanner("hci0", "hci0") cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) yield cancel() @@ -65,7 +65,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") + hci1_scanner = FakeScanner("hci1", "hci1") cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) yield cancel() @@ -562,7 +562,7 @@ async def test_switching_adapters_when_one_goes_away( ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True + hass, FakeScanner("hci2", "hci2"), True ) address = "44:44:33:11:23:45" @@ -612,7 +612,7 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) address = "44:44:33:11:23:45" @@ -704,7 +704,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -877,7 +877,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cb..8cffbe685b6 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -10,9 +10,9 @@ from bleak.backends.scanner import AdvertisementData import pytest from homeassistant.components.bluetooth import ( - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.components.bluetooth.wrappers import ( HaBleakClientWrapper, @@ -107,7 +107,6 @@ async def test_wrapped_bleak_client_local_adapter_only( return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) @@ -158,7 +157,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -271,7 +270,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -336,7 +335,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -439,7 +438,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -553,7 +552,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d..7673acb80dc 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -26,10 +25,19 @@ from . import ( async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +50,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +65,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +85,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +105,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +126,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +148,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +168,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +216,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -227,9 +227,8 @@ async def test_recovery_from_dbus_restart( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -237,9 +236,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # Fire a callback to reset the timer - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -247,9 +245,8 @@ async def test_recovery_from_dbus_restart( ) # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -257,9 +254,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -302,11 +298,10 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -317,9 +312,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -327,9 +321,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -337,13 +330,12 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -391,11 +383,10 @@ async def test_adapter_scanner_fails_to_start_first_time( scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -406,9 +397,8 @@ async def test_adapter_scanner_fails_to_start_first_time( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -416,9 +406,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -426,13 +415,12 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -442,13 +430,12 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +490,15 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,26 +540,22 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 # Now force a recover adapter 2x for _ in range(2): - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): @@ -617,7 +599,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index f69f8971479..d3c2e1b54db 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -12,9 +12,9 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_get_advertisement_callback, ) from homeassistant.components.bluetooth.usage import ( @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device -class FakeScanner(BaseHaRemoteScanner): +class FakeScanner(HomeAssistantRemoteScanner): """Fake scanner.""" def __init__( diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e..00f8a34fb0c 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f..18a70fa9ab6 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import socket import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from .helpers import future_timestamp, static_datetime from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 357371e4853..d38c65526c2 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -186,7 +186,6 @@ async def test_no_lights_or_groups( "state": STATE_ON, "attributes": { ATTR_EFFECT_LIST: [ - EFFECT_COLORLOOP, "carnival", "collide", "fading", diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2..49912fd282f 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def test_connected_device_registered( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c5..2960789c646 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +603,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( 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/discovergy/const.py b/tests/components/discovergy/const.py index 6c5428741af..5e596d7970f 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -67,7 +67,7 @@ LAST_READING = Reading( "energyOut": 55048723044000.0, "energyOut1": 0.0, "energyOut2": 0.0, - "power": 531750.0, + "power": 0.0, "power1": 142680.0, "power2": 138010.0, "power3": 251060.0, diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index 2a7dd6903af..e8d4eab1909 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -61,7 +61,7 @@ 'energyOut': 55048723044000.0, 'energyOut1': 0.0, 'energyOut2': 0.0, - 'power': 531750.0, + 'power': 0.0, 'power1': 142680.0, 'power2': 138010.0, 'power3': 251060.0, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 981d1119a93..2473af5012a 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -132,7 +132,7 @@ 'entity_id': 'sensor.electricity_teststrasse_1_total_power', 'last_changed': , 'last_updated': , - 'state': '531.75', + 'state': '0.0', }) # --- # name: test_sensor[gas last transmitted] diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..422bfa0c35c 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" @@ -474,8 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "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..b42f26f4ccc 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,8 +98,6 @@ async def test_migrate_unique_id( data={ "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..5e31fa7a82e 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -29,8 +29,6 @@ async def test_migrate_gas_to_mbus( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -127,8 +125,6 @@ async def test_migrate_gas_to_mbus_exists( data={ "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..419b562f431 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,8 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -189,8 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -245,8 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -334,8 +328,6 @@ async def test_v5_meter( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -410,8 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -514,8 +504,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -716,8 +704,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -879,8 +865,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -991,8 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1046,8 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1121,8 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1162,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -1175,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -1195,8 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1223,8 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1242,6 +1216,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: @@ -1251,8 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1281,6 +1254,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 ( @@ -1294,8 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1377,8 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9..6f383dcb6ba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c9..474455fc164 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1..e28582ca2e9 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e..eff96ba1bd3 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), diff --git a/tests/components/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000..e906586aabc --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/holiday/conftest.py b/tests/components/holiday/conftest.py new file mode 100644 index 00000000000..d9b0d1a5788 --- /dev/null +++ b/tests/components/holiday/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Holiday 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.holiday.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000..06011fb8e6b --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +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, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000..e99d310762e --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + 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_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000..a044e390a68 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac1..5c7e71ea9ac 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 7dab8cfbb06..a4893c77f42 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -118,3 +120,104 @@ async def test_load_handles_homewizardenergy_exception( ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, ) + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration_does_not_trigger( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are not migrated when not possible.""" + mock_config_entry.add_to_hass(hass) + + old_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + new_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=new_unique_id, + config_entry=mock_config_entry, + ) + + assert old_entity.unique_id == old_unique_id + assert new_entity.unique_id == new_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = entity_registry.async_get(old_entity.entity_id) + assert entity + assert entity.unique_id == old_unique_id + assert entity.previous_unique_id is None + + entity = entity_registry.async_get(new_entity.entity_id) + assert entity + assert entity.unique_id == new_unique_id + assert entity.previous_unique_id is None diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ebd8d80ece2..a54f98899c6 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,7 +64,7 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError with pytest.raises( @@ -97,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "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.""" + """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 2f6e777a3a8..61ca34fab7a 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ pytestmark = [ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000..c3f6ded65b6 --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + 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( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16..19338d8d576 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd..f05d12e49a2 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 16f40fda786..637acc22d05 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -9,10 +9,6 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, - DOMAIN, - SERVICE_LOCK, - SERVICE_OPEN, - SERVICE_UNLOCK, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, @@ -20,11 +16,8 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -87,7 +80,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_locking assert lock.state == STATE_LOCKING - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() assert lock.is_locked assert lock.state == STATE_LOCKED @@ -95,7 +88,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_unlocking assert lock.state == STATE_UNLOCKING - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() assert not lock.is_locked assert lock.state == STATE_UNLOCKED @@ -189,12 +182,12 @@ async def test_lock_open_with_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) + await lock.async_handle_open_service(code="HELLO") + await lock.async_handle_open_service(code="1234") assert lock.calls_open.call_count == 1 @@ -203,16 +196,16 @@ async def test_lock_lock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="HELLO") + await lock.async_handle_lock_service(code="1234") assert lock.is_locked @@ -221,18 +214,16 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="1234") assert lock.is_locked with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) - ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="HELLO") + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked @@ -245,17 +236,11 @@ async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: lock.hass = hass with pytest.raises(ValueError): - await _async_open( - lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_open_service(code="123456") with pytest.raises(ValueError): - await _async_lock( - lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_lock_service(code="123456") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_unlock_service(code="123456") async def test_lock_with_no_code(hass: HomeAssistant) -> None: @@ -265,18 +250,18 @@ async def test_lock_with_no_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({}) @@ -292,18 +277,18 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} assert lock._lock_option_default_code == "1234" - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) @@ -316,11 +301,11 @@ async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + await lock.async_handle_open_service(code="4321") lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_lock_service(code="4321") lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_unlock_service(code="4321") lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) @@ -337,8 +322,8 @@ async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "123456" with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d95b409a67b..671c70168d2 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -504,10 +505,7 @@ async def test_logbook_describe_event( ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -569,10 +567,7 @@ async def test_exclude_described_event( }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a..4181d73c4d3 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b8..a772b37f047 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6..4a280eccfda 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605d..72e7adb3a13 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") 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_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edc..991d23f3353 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +102,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -137,6 +139,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) 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" diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1eca..f3d49f3c0bc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a892dd205fb..e47a6165b30 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74..325b68869e0 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,17 +1,36 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -22,7 +41,6 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +58,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +95,6 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -186,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -196,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -341,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -532,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -548,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} @@ -581,46 +792,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f70..39897822bc8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a..0922329d4b7 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ from homeassistant.components.modbus.const import ( CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9..df415807119 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12..ecd9abd71b8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index d0a4e23f780..8fb7f9fd951 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -19,7 +17,6 @@ from homeassistant.components.modbus.const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -50,7 +47,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -81,7 +78,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -98,7 +94,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -125,7 +120,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +222,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -555,7 +548,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -1146,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ @@ -1290,7 +1247,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1306,7 +1262,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c082..28c44440581 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..d7250356ebe --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 65bbff0e601..8686c52d79b 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -142,12 +142,20 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] + ("items", "category"), + [ + ( + [{"id": "12345", "name": "Soda", "categoryId": "test_category"}], + "test_category", + ), + ([{"id": "12345", "name": "Uncategorized"}], None), + ], ) async def test_update_todo_item_summary( hass: HomeAssistant, setup_integration: None, ourgroceries: AsyncMock, + category: str | None, ) -> None: """Test for updating an item summary.""" @@ -171,7 +179,7 @@ async def test_update_todo_item_summary( ) assert ourgroceries.change_item_on_list args = ourgroceries.change_item_on_list.call_args - assert args.args == ("test_list", "12345", "test_category", "Milk") + assert args.args == ("test_list", "12345", category, "Milk") @pytest.mark.parametrize( diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index ca6759baeff..9ce87d707ff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -78,12 +78,6 @@ async def test_meter_value_error(hass: HomeAssistant) -> None: ) 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"} @@ -107,12 +101,6 @@ async def test_incompatible_meter_error(hass: HomeAssistant) -> None: ) 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" @@ -135,12 +123,6 @@ async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: ) 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"} @@ -164,12 +146,6 @@ async def test_meter_http_error(hass: HomeAssistant) -> None: ) 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"} @@ -193,12 +169,6 @@ async def test_smart_meter(hass: HomeAssistant) -> None: ) 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" 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 diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b30738865..3834254ac7f 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d..e35643d7626 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index df90af44e73..7ded4fb0aed 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -62,8 +62,8 @@ 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" - in caplog.text + "Invalid config for 'switch' from integration 'rest': required key 'resource' " + "not provided" in caplog.text ) @@ -75,7 +75,10 @@ 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' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8..e5a5c73de39 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51..12af639b251 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..182b45d9c1b 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 @@ -10,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 @@ -110,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", ), ( @@ -132,15 +134,52 @@ 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, + 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), pytest.raises( + ServiceValidationError + ) as exc: + result, _ = message.send_message(message_data, data=data) + assert content_type in result + 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: """Verify if we can send simple text message.""" expected = ( diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 35f03ee9508..88f0fc366a3 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' from integration 'template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) 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( diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index ddd980ae970..b525c7a8fa3 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,398 +1,247 @@ """The test for the Trend sensor platform.""" from datetime import timedelta import logging +from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, +from tests.common import assert_setup_component, get_fixture_path, mock_restore_cache + + +async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": params, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], ) +async def test_basic_trend( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +): + """Test trend with a basic setup.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "invert": inverted, + }, + ) + + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state -class TestTrendBinarySensor: - """Test the Trend sensor.""" +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +): + """Test uptrend using multiple samples and trendline calculation.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) - hass = None + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + attr_values: list[str], + expected_state: str, +): + """Test attribute uptrend.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples(hass: HomeAssistant): + """Test that sample count is limited correctly.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric(hass: HomeAssistant): + """Test for non-numeric sensor.""" + await _setup_component(hass, {"entity_id": "sensor.test_state"}) + + hass.states.async_set("sensor.test_state", "Non") + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "Numeric") + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_missing_attribute(hass: HomeAssistant): + """Test for missing attribute.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "missing", + }, + ) + + hass.states.async_set("sensor.test_state", "State", {"attr": "2"}) + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "State", {"attr": "1"}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_invalid_name_does_not_create(hass: HomeAssistant): + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} + "test INVALID sensor": {"entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, +async def test_invalid_sensor_does_not_create(hass: HomeAssistant): + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - now = dt_util.utcnow() - # add not enough states to trigger calculation - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert ( - self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown" +async def test_no_sensors_does_not_create(hass: HomeAssistant): + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} ) - - # add one more state to trigger gradient calculation - for val in [100]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on" - - # add more states to trigger a downtrend - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] + assert hass.states.async_all("binary_sensor") == [] async def test_reload(hass: HomeAssistant) -> None: @@ -436,83 +285,65 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } - }, - } + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state - now = dt_util.utcnow() - # add not enough samples to trigger calculation for val in [10, 20, 30, 40]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # state should match restored state as no calculation happened assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state # add more samples to trigger calculation for val in [50, 60, 70, 80]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # sensor should detect an upwards trend and turn on assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" async def test_invalid_min_sample( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if error is logged when min_sample is larger than max_samples.""" with caplog.at_level(logging.ERROR): - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 25, - "min_samples": 30, - } - }, - } + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, }, ) - await hass.async_block_till_done() record = caplog.records[0] assert record.levelname == "ERROR" assert ( - "Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples" - in record.message + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11..5be56edbc32 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253c..0ac8140c52d 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3..d77c2db356a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 3890d6a28d1..b982ab610ec 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1286,7 +1286,7 @@ async def test_issue_forecast_deprecated_no_logging( assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" 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_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000..93e5830a0ac --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237a..7113e1d4d51 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd5..74573e2185b 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 7457d2e0ada..a359d83d87d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -316,6 +316,18 @@ async def test_check_date_service( ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 17), # Saturday (no workday) + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + async def test_language_difference_english_language( hass: HomeAssistant, diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..268ebef1d06 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,14 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -13,6 +16,10 @@ from wyoming.info import ( WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -72,24 +79,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,8 +124,24 @@ 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 return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + 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_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..f22ec7e9e16 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,29 @@ 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) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@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 +125,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/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 784f89b2ab8..b45b7508b28 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -6,7 +6,7 @@ 'language': 'en', }), 'payload': None, - 'type': 'transcibe', + 'type': 'transcribe', }), dict({ 'data': dict({ diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..fba181a63ca --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,37 @@ +"""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 + +from . import reload_satellite + + +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 + + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_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 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_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000..084021d61a7 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..50252007aa5 --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,508 @@ +"""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.error import Error +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.error_event = asyncio.Event() + self.error: Error | 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() + elif Error.is_type(event.type): + self.error = Error.from_event(event) + self.error_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_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id + + # 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 + + +async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite error occurring during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + 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: + await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test code", "message": "test message"}, + ) + ) + + async with asyncio.timeout(1): + await mock_client.error_event.wait() + + assert mock_client.error is not None + assert mock_client.error.text == "test message" + assert mock_client.error.code == "test code" diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..128aab57a1a --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,141 @@ +"""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 + +from . import reload_satellite + + +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" + + # set function should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "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() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..a39b7087f6d --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,41 @@ +"""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 + +from . import reload_satellite + + +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 + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 2f2a25558e4..301074e8ffb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -@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 - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba..6fc3259a4c0 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4e..26b9459cfc2 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ async def test_zwave_js_value_updated( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ async def test_zwave_js_event( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/conftest.py b/tests/conftest.py index fcd8e8b73a9..4d0e2565164 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1574,14 +1574,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 9c3d1eb190b..49db89f45ba 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -56,3 +56,8 @@ custom_validator_bad_1: # This always raises ValueError custom_validator_bad_2: + +# Invalid domains +"iot_domain ": +"": +5: 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 5744e3005fa..8e1c75c3511 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -8,3 +8,6 @@ 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 +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index b8116b5988e..25d734b126a 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -68,3 +68,10 @@ homeassistant: pack_custom_validator_bad_2: # This always raises ValueError custom_validator_bad_2: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000..70bf80a6b64 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000..510d4682445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000..49b5720a536 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b65f09aeaf9..de57fa0a8f3 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -227,7 +227,12 @@ async def test_platform_not_found(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -361,7 +366,7 @@ async def test_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc..bcb6f4fa971 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6d1945f2d5f..f997e3a6c10 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), @@ -1624,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293..46716263d5b 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -119,32 +119,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +190,24 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +216,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +242,8 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 40e25633992..60d0774b549 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -317,7 +317,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components async def test_setup_entry(hass: HomeAssistant) -> None: @@ -680,7 +680,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 721114c1a7b..dfaec4577aa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -268,7 +268,7 @@ async def test_platform_error_slow_setup( await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -833,7 +833,7 @@ async def test_setup_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1669,7 +1669,7 @@ async def test_setup_entry_with_entities_that_block_forever( ): assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{platform.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_event.py b/tests/helpers/test_event.py index 00ad580693e..245354a09a0 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ async def test_track_template_error_can_recover( async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ async def test_track_template_time_change( start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) - - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc..39b387000ca 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) -def test_config_per_platform() -> None: + +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 586dbc19eb8..4425ce00ce1 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -53,7 +53,7 @@ async def test_reload_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -93,7 +93,7 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -134,7 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -186,7 +186,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f01718d6af6..d69996e5d29 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -508,7 +508,7 @@ async def test_restore_entity_end_to_end( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7e655a69c0a..c2bad6287ab 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1346,13 +1347,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1379,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab82..b6dc1616a48 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 79358ec588d..d1294d02f05 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1853,7 +1853,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1867,7 +1867,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1954,7 +1954,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2026,7 +2026,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b4253218..62152299932 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -255,7 +255,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +263,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +276,13 @@ async def test_translation_merging( assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +308,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +323,7 @@ async def test_translation_merging_loaded_apart( assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e0869..425ad561f50 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 7438bda5cde..76d3f0c4666 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,24 +1,36 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, '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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -57,24 +69,36 @@ # --- # name: test_component_config_validation_error[basic_include] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, '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' from integration '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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -119,18 +143,18 @@ }), 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' from integration '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' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -143,42 +167,54 @@ }), 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' from integration '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' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), ]) # --- # name: test_component_config_validation_error[packages] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), dict({ 'has_exc_info': False, '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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -217,6 +253,18 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), 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", @@ -255,31 +303,34 @@ }), 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' from integration '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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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] list([ + "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", "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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', 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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration '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", diff --git a/tests/test_config.py b/tests/test_config.py index de5e7e0581d..8ec509cd895 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2043,7 +2043,7 @@ async def test_component_config_validation_error( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2207,3 +2207,36 @@ async def test_yaml_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7..ce1767f2755 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -36,6 +37,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -399,6 +401,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -1102,7 +1130,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() @@ -2566,3 +2594,30 @@ def test_hassjob_passing_job_type(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() diff --git a/tests/test_runner.py b/tests/test_runner.py index 3b06e3b64dc..14728321721 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ TIMEOUT_SAFETY_MARGIN = 10 async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN diff --git a/tests/test_setup.py b/tests/test_setup.py index 00bb3fa2a2d..14c56d39a5a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -672,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -729,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000..514502131fb --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_color.py b/tests/util/test_color.py index a7e6ba9ab46..5dd20d8d887 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -587,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400..a973135d831 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -737,8 +736,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000..5fef6cf806b --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 3a2d9b3734d..1e31d8c6955 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -134,6 +134,7 @@ def test_include_yaml( [ ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) def test_include_dir_list( @@ -190,6 +191,10 @@ def test_include_dir_list_recursive( {"/test/first.yaml": "1", "/test/second.yaml": "2"}, {"first": 1, "second": 2}, ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), ], ) def test_include_dir_named( @@ -249,6 +254,10 @@ def test_include_dir_named_recursive( {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, [1, 2, 3], ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), ], ) def test_include_dir_merge_list( @@ -311,6 +320,13 @@ def test_include_dir_merge_list_recursive( }, {"key1": 1, "key2": 2, "key3": 3}, ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) def test_include_dir_merge_named( @@ -590,7 +606,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 +630,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 +644,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 called from hue, this is a deprecated class. " - f"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: @@ -689,3 +702,20 @@ def test_string_used_as_vol_schema(try_both_loaders) -> None: schema({"key_1": "value_1", "key_2": "value_2"}) with pytest.raises(vol.Invalid): schema({"key_1": "value_2", "key_2": "value_1"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)