diff --git a/.core_files.yaml b/.core_files.yaml index df69df45cb6b12..4082c016d8f899 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -46,7 +46,6 @@ base_platforms: &base_platforms # Extra components that trigger the full suite components: &components - - homeassistant/components/alert/** - homeassistant/components/alexa/** - homeassistant/components/application_credentials/** - homeassistant/components/auth/** diff --git a/.coveragerc b/.coveragerc index 6c31546e718384..9d33acf6c75baf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -37,6 +37,8 @@ omit = homeassistant/components/airnow/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py + homeassistant/components/airthings_ble/__init__.py + homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py @@ -159,6 +161,8 @@ omit = homeassistant/components/brunt/const.py homeassistant/components/brunt/cover.py homeassistant/components/bsblan/climate.py + homeassistant/components/bsblan/const.py + homeassistant/components/bsblan/entity.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py @@ -405,9 +409,11 @@ omit = homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py homeassistant/components/flume/__init__.py + homeassistant/components/flume/binary_sensor.py homeassistant/components/flume/coordinator.py homeassistant/components/flume/entity.py homeassistant/components/flume/sensor.py + homeassistant/components/flume/util.py homeassistant/components/folder/sensor.py homeassistant/components/folder_watcher/* homeassistant/components/foobot/sensor.py @@ -534,6 +540,7 @@ omit = homeassistant/components/hunterdouglas_powerview/entity.py homeassistant/components/hunterdouglas_powerview/model.py homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hunterdouglas_powerview/select.py homeassistant/components/hunterdouglas_powerview/sensor.py homeassistant/components/hunterdouglas_powerview/shade_data.py homeassistant/components/hunterdouglas_powerview/util.py @@ -578,6 +585,7 @@ omit = homeassistant/components/intellifire/coordinator.py homeassistant/components/intellifire/entity.py homeassistant/components/intellifire/fan.py + homeassistant/components/intellifire/number.py homeassistant/components/intellifire/sensor.py homeassistant/components/intellifire/switch.py homeassistant/components/intesishome/* @@ -608,7 +616,6 @@ omit = homeassistant/components/izone/__init__.py homeassistant/components/izone/climate.py homeassistant/components/izone/discovery.py - homeassistant/components/jellyfin/__init__.py homeassistant/components/jellyfin/media_source.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/__init__.py @@ -657,8 +664,6 @@ omit = homeassistant/components/kostal_plenticore/switch.py homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py - homeassistant/components/lametric/notify.py - homeassistant/components/lametric/number.py homeassistant/components/lannouncer/notify.py homeassistant/components/lastfm/sensor.py homeassistant/components/launch_library/__init__.py @@ -855,6 +860,7 @@ omit = homeassistant/components/noaa_tides/sensor.py homeassistant/components/nobo_hub/__init__.py homeassistant/components/nobo_hub/climate.py + homeassistant/components/nobo_hub/sensor.py homeassistant/components/norway_air/air_quality.py homeassistant/components/notify_events/notify.py homeassistant/components/notion/__init__.py @@ -1101,11 +1107,10 @@ omit = homeassistant/components/sesame/lock.py homeassistant/components/seven_segments/image_processing.py homeassistant/components/seventeentrack/sensor.py - homeassistant/components/shelly/__init__.py homeassistant/components/shelly/binary_sensor.py homeassistant/components/shelly/climate.py + homeassistant/components/shelly/coordinator.py homeassistant/components/shelly/entity.py - homeassistant/components/shelly/light.py homeassistant/components/shelly/number.py homeassistant/components/shelly/sensor.py homeassistant/components/shelly/utils.py @@ -1161,6 +1166,7 @@ omit = homeassistant/components/smtp/notify.py homeassistant/components/snapcast/* homeassistant/components/snmp/* + homeassistant/components/snooz/__init__.py homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/coordinator.py homeassistant/components/solaredge/sensor.py @@ -1230,7 +1236,9 @@ omit = homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py homeassistant/components/switchbee/button.py + homeassistant/components/switchbee/climate.py homeassistant/components/switchbee/coordinator.py + homeassistant/components/switchbee/cover.py homeassistant/components/switchbee/entity.py homeassistant/components/switchbee/light.py homeassistant/components/switchbee/switch.py @@ -1421,6 +1429,7 @@ omit = homeassistant/components/velbus/const.py homeassistant/components/velbus/cover.py homeassistant/components/velbus/diagnostics.py + homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py homeassistant/components/velbus/switch.py @@ -1573,6 +1582,9 @@ omit = homeassistant/components/youless/const.py homeassistant/components/youless/sensor.py homeassistant/components/zabbix/* + homeassistant/components/zamg/__init__.py + homeassistant/components/zamg/const.py + homeassistant/components/zamg/coordinator.py homeassistant/components/zamg/sensor.py homeassistant/components/zamg/weather.py homeassistant/components/zengge/light.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ba2911dcf0c304..1711ab68fdee61 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "1" }, - "appPort": 8123, + "appPort": ["8123:8123"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], "extensions": [ "ms-python.vscode-pylance", @@ -17,8 +17,14 @@ // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.pythonPath": "/usr/local/bin/python", - "python.linting.pylintEnabled": true, "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.blackPath": "/usr/local/bin/black", + "python.linting.flake8Path": "/usr/local/bin/flake8", + "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", + "python.linting.mypyPath": "/usr/local/bin/mypy", + "python.linting.pylintPath": "/usr/local/bin/pylint", "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6b13f0980b199f..0390910cc58ad0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) + [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 52d2522693099c..23b355a223fd5e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -75,18 +75,6 @@ If the code communicates with devices, web services, or third-party tools: - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] Untested files have been added to `.coveragerc`. -The integration reached or maintains the following [Integration Quality Scale][quality-scale]: - - -- [ ] No score or internal -- [ ] 🥈 Silver -- [ ] 🥇 Gold -- [ ] 🏆 Platinum - ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry() # - user(None): scan --> user({...}) --> create_entry() - # - import(None) --> create_entry() def __init__(self) -> None: """Initialize the UPnP/IGD config flow.""" diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 023ec82a4876a8..8d98790983a41c 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -11,13 +11,16 @@ BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" +KIBIBYTES_PER_SEC_RECEIVED = "kibibytes_per_sec_received" +KIBIBYTES_PER_SEC_SENT = "kibibytes_per_sec_sent" +PACKETS_PER_SEC_RECEIVED = "packets_per_sec_received" +PACKETS_PER_SEC_SENT = "packets_per_sec_sent" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" WAN_STATUS = "wan_status" ROUTER_IP = "ip" ROUTER_UPTIME = "uptime" -KIBIBYTE = 1024 CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_UDN = "udn" CONFIG_ENTRY_ORIGINAL_UDN = "original_udn" diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py new file mode 100644 index 00000000000000..18d37b4a3886e2 --- /dev/null +++ b/homeassistant/components/upnp/coordinator.py @@ -0,0 +1,50 @@ +"""UPnP/IGD coordinator.""" + +from collections.abc import Mapping +from datetime import timedelta +from typing import Any + +from async_upnp_client.exceptions import UpnpCommunicationError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER +from .device import Device + + +class UpnpDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to update data from UPNP device.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + device_entry: DeviceEntry, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.device = device + self.device_entry = device_entry + + super().__init__( + hass, + LOGGER, + name=device.name, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> Mapping[str, Any]: + """Update data.""" + try: + return await self.device.async_get_data() + except UpnpCommunicationError as exception: + LOGGER.debug( + "Caught exception when updating device: %s, exception: %s", + self.device, + exception, + ) + raise UpdateFailed( + f"Unable to communicate with IGD at: {self.device.device_url}" + ) from exception diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index e06ada02b77705..61784749c6f39d 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,7 +1,6 @@ """Home Assistant representation of an UPnP/IGD.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from functools import partial from ipaddress import ip_address @@ -10,19 +9,21 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.client_factory import UpnpFactory -from async_upnp_client.exceptions import UpnpError -from async_upnp_client.profiles.igd import IgdDevice, StatusInfo +from async_upnp_client.profiles.igd import IgdDevice from getmac import get_mac_address from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.dt import utcnow from .const import ( BYTES_RECEIVED, BYTES_SENT, + KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_SENT, LOGGER as _LOGGER, + PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_SENT, PACKETS_RECEIVED, PACKETS_SENT, ROUTER_IP, @@ -51,7 +52,7 @@ async def async_create_device(hass: HomeAssistant, ssdp_location: str) -> Device session = async_get_clientsession(hass, verify_ssl=False) requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20) - factory = UpnpFactory(requester, disable_state_variable_validation=True) + factory = UpnpFactory(requester, non_strict=True) upnp_device = await factory.async_create_device(ssdp_location) # Create profile wrapper. @@ -134,69 +135,35 @@ def __str__(self) -> str: """Get string representation.""" return f"IGD Device: {self.name}/{self.udn}::{self.device_type}" - async def async_get_traffic_data(self) -> Mapping[str, Any]: - """ - Get all traffic data in one go. - - Traffic data consists of: - - total bytes sent - - total bytes received - - total packets sent - - total packats received - - Data is timestamped. - """ - _LOGGER.debug("Getting traffic statistics from device: %s", self) - - values = await asyncio.gather( - self._igd_device.async_get_total_bytes_received(), - self._igd_device.async_get_total_bytes_sent(), - self._igd_device.async_get_total_packets_received(), - self._igd_device.async_get_total_packets_sent(), - ) + async def async_get_data(self) -> Mapping[str, Any]: + """Get all data from device.""" + _LOGGER.debug("Getting data for device: %s", self) + igd_state = await self._igd_device.async_get_traffic_and_status_data() + status_info = igd_state.status_info + if status_info is not None and not isinstance(status_info, Exception): + wan_status = status_info.connection_status + router_uptime = status_info.uptime + else: + wan_status = None + router_uptime = None - return { - TIMESTAMP: utcnow(), - BYTES_RECEIVED: values[0], - BYTES_SENT: values[1], - PACKETS_RECEIVED: values[2], - PACKETS_SENT: values[3], - } + def get_value(value: Any) -> Any: + if value is None or isinstance(value, Exception): + return None - async def async_get_status(self) -> Mapping[str, Any]: - """Get connection status, uptime, and external IP.""" - _LOGGER.debug("Getting status for device: %s", self) - - values = await asyncio.gather( - self._igd_device.async_get_status_info(), - self._igd_device.async_get_external_ip_address(), - return_exceptions=True, - ) - status_info: StatusInfo | None = None - router_ip: str | None = None - - for idx, value in enumerate(values): - if isinstance(value, UpnpError): - # Not all routers support some of these items although based - # on defined standard they should. - _LOGGER.debug( - "Exception occurred while trying to get status %s for device %s: %s", - "status" if idx == 1 else "external IP address", - self, - str(value), - ) - continue - - if isinstance(value, Exception): - raise value - - if isinstance(value, StatusInfo): - status_info = value - elif isinstance(value, str): - router_ip = value + return value return { - WAN_STATUS: status_info[0] if status_info is not None else None, - ROUTER_UPTIME: status_info[2] if status_info is not None else None, - ROUTER_IP: router_ip, + TIMESTAMP: igd_state.timestamp, + BYTES_RECEIVED: get_value(igd_state.bytes_received), + BYTES_SENT: get_value(igd_state.bytes_sent), + PACKETS_RECEIVED: get_value(igd_state.packets_received), + PACKETS_SENT: get_value(igd_state.packets_sent), + WAN_STATUS: wan_status, + ROUTER_UPTIME: router_uptime, + ROUTER_IP: get_value(igd_state.external_ip_address), + KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received, + KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent, + PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received, + PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent, } diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py new file mode 100644 index 00000000000000..b787018adcc945 --- /dev/null +++ b/homeassistant/components/upnp/entity.py @@ -0,0 +1,54 @@ +"""Entity for UPnP/IGD.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import UpnpDataUpdateCoordinator + + +@dataclass +class UpnpEntityDescription(EntityDescription): + """UPnP entity description.""" + + format: str = "s" + unique_id: str | None = None + value_key: str | None = None + + def __post_init__(self): + """Post initialize.""" + self.value_key = self.value_key or self.key + + +class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): + """Base class for UPnP/IGD entities.""" + + entity_description: UpnpEntityDescription + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpEntityDescription, + ) -> None: + """Initialize the base entities.""" + super().__init__(coordinator) + self._device = coordinator.device + self.entity_description = entity_description + self._attr_name = f"{coordinator.device.name} {entity_description.name}" + self._attr_unique_id = f"{coordinator.device.original_udn}_{entity_description.unique_id or entity_description.key}" + self._attr_device_info = DeviceInfo( + connections=coordinator.device_entry.connections, + name=coordinator.device_entry.name, + manufacturer=coordinator.device_entry.manufacturer, + model=coordinator.device_entry.model, + configuration_url=coordinator.device_entry.configuration_url, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self.coordinator.data.get(self.entity_description.key) is not None + ) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index a4b913ec4c8a34..9b4151c35c5180 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,9 +3,9 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.31.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.32.1", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], - "codeowners": ["@StevenLooman", "@ehendrix23"], + "codeowners": ["@StevenLooman"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" @@ -15,5 +15,6 @@ } ], "iot_class": "local_polling", - "loggers": ["async_upnp_client"] + "loggers": ["async_upnp_client"], + "integration_type": "device" } diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 53a918ba053a4f..3d0c71fafdb23b 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,31 +1,46 @@ """Support for UPnP/IGD Sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity, UpnpSensorEntityDescription from .const import ( BYTES_RECEIVED, BYTES_SENT, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, DOMAIN, - KIBIBYTE, + KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_SENT, LOGGER, + PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_SENT, PACKETS_RECEIVED, PACKETS_SENT, ROUTER_IP, ROUTER_UPTIME, - TIMESTAMP, WAN_STATUS, ) +from .coordinator import UpnpDataUpdateCoordinator +from .entity import UpnpEntity, UpnpEntityDescription + -RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( +@dataclass +class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription): + """A class that describes a sensor UPnP entities.""" + + +SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=BYTES_RECEIVED, name=f"{DATA_BYTES} received", @@ -33,6 +48,7 @@ native_unit_of_measurement=DATA_BYTES, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -41,6 +57,7 @@ native_unit_of_measurement=DATA_BYTES, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, @@ -49,6 +66,7 @@ native_unit_of_measurement=DATA_PACKETS, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=PACKETS_SENT, @@ -57,11 +75,13 @@ native_unit_of_measurement=DATA_PACKETS, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=ROUTER_IP, name="External IP", icon="mdi:server-network", + entity_category=EntityCategory.DIAGNOSTIC, ), UpnpSensorEntityDescription( key=ROUTER_UPTIME, @@ -79,42 +99,47 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), -) - -DERIVED_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=BYTES_RECEIVED, + value_key=KIBIBYTES_PER_SEC_RECEIVED, unique_id="KiB/sec_received", name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, format=".1f", + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=BYTES_SENT, + value_key=KIBIBYTES_PER_SEC_SENT, unique_id="KiB/sec_sent", name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, format=".1f", + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, + value_key=PACKETS_PER_SEC_RECEIVED, unique_id="packets/sec_received", name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, format=".1f", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=PACKETS_SENT, + value_key=PACKETS_PER_SEC_SENT, unique_id="packets/sec_sent", name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, format=".1f", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -125,26 +150,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[UpnpSensor] = [ - RawUpnpSensor( + UpnpSensor( coordinator=coordinator, entity_description=entity_description, ) - for entity_description in RAW_SENSORS + for entity_description in SENSOR_DESCRIPTIONS if coordinator.data.get(entity_description.key) is not None ] - entities.extend( - [ - DerivedUpnpSensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in DERIVED_SENSORS - if coordinator.data.get(entity_description.key) is not None - ] - ) LOGGER.debug("Adding sensor entities: %s", entities) async_add_entities(entities) @@ -155,64 +170,10 @@ class UpnpSensor(UpnpEntity, SensorEntity): entity_description: UpnpSensorEntityDescription - -class RawUpnpSensor(UpnpSensor): - """Representation of a UPnP/IGD sensor.""" - @property def native_value(self) -> str | None: """Return the state of the device.""" - value = self.coordinator.data[self.entity_description.key] + value = self.coordinator.data[self.entity_description.value_key] if value is None: return None return format(value, self.entity_description.format) - - -class DerivedUpnpSensor(UpnpSensor): - """Representation of a UNIT Sent/Received per second sensor.""" - - def __init__( - self, - coordinator: UpnpDataUpdateCoordinator, - entity_description: UpnpSensorEntityDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator=coordinator, entity_description=entity_description) - self._last_value = None - self._last_timestamp = None - - def _has_overflowed(self, current_value) -> bool: - """Check if value has overflowed.""" - return current_value < self._last_value - - @property - def native_value(self) -> str | None: - """Return the state of the device.""" - # Can't calculate any derivative if we have only one value. - current_value = self.coordinator.data[self.entity_description.key] - if current_value is None: - return None - current_timestamp = self.coordinator.data[TIMESTAMP] - if self._last_value is None or self._has_overflowed(current_value): - self._last_value = current_value - self._last_timestamp = current_timestamp - return None - - # Calculate derivative. - delta_value = current_value - self._last_value - if ( - self.entity_description.native_unit_of_measurement - == DATA_RATE_KIBIBYTES_PER_SECOND - ): - delta_value /= KIBIBYTE - delta_time = current_timestamp - self._last_timestamp - if delta_time.total_seconds() == 0: - # Prevent division by 0. - return None - derived = delta_value / delta_time.total_seconds() - - # Store current values for future use. - self._last_value = current_value - self._last_timestamp = current_timestamp - - return format(derived, self.entity_description.format) diff --git a/homeassistant/components/upnp/translations/tr.json b/homeassistant/components/upnp/translations/tr.json index 4742549eaff71f..7d24215a47e407 100644 --- a/homeassistant/components/upnp/translations/tr.json +++ b/homeassistant/components/upnp/translations/tr.json @@ -13,7 +13,7 @@ "step": { "init": { "one": "Bo\u015f", - "other": "" + "other": "Bo\u015f" }, "ssdp_confirm": { "description": "Bu UPnP / IGD cihaz\u0131n\u0131 kurmak istiyor musunuz?" diff --git a/homeassistant/components/uprise_smart_shades/manifest.json b/homeassistant/components/uprise_smart_shades/manifest.json new file mode 100644 index 00000000000000..a0ddc2bfb2f83d --- /dev/null +++ b/homeassistant/components/uprise_smart_shades/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "uprise_smart_shades", + "name": "Uprise Smart Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/uptime/manifest.json b/homeassistant/components/uptime/manifest.json index 3bcc47815f8262..ef81472569923a 100644 --- a/homeassistant/components/uptime/manifest.json +++ b/homeassistant/components/uptime/manifest.json @@ -5,5 +5,6 @@ "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push", + "integration_type": "service", "config_flow": true } diff --git a/homeassistant/components/uptime/translations/ca.json b/homeassistant/components/uptime/translations/ca.json index bbd6caebc119c7..ef0b636dcf7c05 100644 --- a/homeassistant/components/uptime/translations/ca.json +++ b/homeassistant/components/uptime/translations/ca.json @@ -11,8 +11,9 @@ }, "issues": { "removed_yaml": { - "title": "La configuraci\u00f3 YAML d'Uptime s'ha eliminat" + "description": "La configuraci\u00f3 de data i temps d'engegada mitjan\u00e7ant YAML s'ha eliminat.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML corresponent del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de data i temps d'engegada s'ha eliminat" } }, - "title": "Temps en funcionament" + "title": "Data i temps d'engegada" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/el.json b/homeassistant/components/uptime/translations/el.json index d70141f2173868..7e338844e9ddba 100644 --- a/homeassistant/components/uptime/translations/el.json +++ b/homeassistant/components/uptime/translations/el.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u0397 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03bf\u03c5 Uptime \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af.\n\n\u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b4\u03b5\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf Home Assistant.\n\n\u0391\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03c4\u03bf\u03c5 Uptime \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af" + } + }, "title": "\u03a7\u03c1\u03cc\u03bd\u03bf\u03c2 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/et.json b/homeassistant/components/uptime/translations/et.json index f1b40328dab2c5..71ef4a5c265436 100644 --- a/homeassistant/components/uptime/translations/et.json +++ b/homeassistant/components/uptime/translations/et.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Uptime konfigureerimine YAML-i abil on eemaldatud. \n\n Koduassistent ei kasuta teie olemasolevat YAML-i konfiguratsiooni. \n\n Selle probleemi lahendamiseks eemaldage YAML-i konfiguratsioon failist configuration.yaml ja taask\u00e4ivitage Home Assistant.", + "title": "Uptime YAML-i konfiguratsioon on eemaldatud" + } + }, "title": "T\u00f6\u00f6aeg" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/hu.json b/homeassistant/components/uptime/translations/hu.json index 241c28b48eadee..bae1e5edabc218 100644 --- a/homeassistant/components/uptime/translations/hu.json +++ b/homeassistant/components/uptime/translations/hu.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Az Uptime YAML haszn\u00e1lat\u00e1val t\u00f6rt\u00e9n\u0151 konfigur\u00e1l\u00e1sa elt\u00e1vol\u00edt\u00e1sra ker\u00fclt.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3t a Home Assistant nem haszn\u00e1lja.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "Az Uptime YAML konfigur\u00e1ci\u00f3 elt\u00e1vol\u00edt\u00e1sra ker\u00fclt" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/id.json b/homeassistant/components/uptime/translations/id.json index bf6ea606f2b96b..33b92602016eeb 100644 --- a/homeassistant/components/uptime/translations/id.json +++ b/homeassistant/components/uptime/translations/id.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Proses konfigurasi Integrasi Uptime lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Uptime telah dihapus" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/it.json b/homeassistant/components/uptime/translations/it.json index 9913180a3094c1..f842f6d6aa9166 100644 --- a/homeassistant/components/uptime/translations/it.json +++ b/homeassistant/components/uptime/translations/it.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configurazione di Uptime tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente non viene utilizzata da Home Assistant. \n\nRimuovere la configurazione YAML dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Uptime \u00e8 stata rimossa" + } + }, "title": "Tempo di funzionamento" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/nb.json b/homeassistant/components/uptime/translations/nb.json new file mode 100644 index 00000000000000..13c4d0e9385efa --- /dev/null +++ b/homeassistant/components/uptime/translations/nb.json @@ -0,0 +1,3 @@ +{ + "title": "Oppetid" +} \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/nl.json b/homeassistant/components/uptime/translations/nl.json index b4ed0a1db36b78..bd054cfefc70e0 100644 --- a/homeassistant/components/uptime/translations/nl.json +++ b/homeassistant/components/uptime/translations/nl.json @@ -9,5 +9,10 @@ } } }, + "issues": { + "removed_yaml": { + "title": "De Uptime YAML-configuratie is verwijderd" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/no.json b/homeassistant/components/uptime/translations/no.json index 9ac16f0a20c229..fa9103dff3cd88 100644 --- a/homeassistant/components/uptime/translations/no.json +++ b/homeassistant/components/uptime/translations/no.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Oppetid med YAML er fjernet. \n\n Din eksisterende YAML-konfigurasjon brukes ikke av Home Assistant. \n\n Fjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Oppetid YAML-konfigurasjonen er fjernet" + } + }, "title": "Oppetid" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/pl.json b/homeassistant/components/uptime/translations/pl.json index bca14b2f14c08d..2e5f40c3bd7dd5 100644 --- a/homeassistant/components/uptime/translations/pl.json +++ b/homeassistant/components/uptime/translations/pl.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfiguracja Uptime za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Uptime zosta\u0142a usuni\u0119ta" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/pt-BR.json b/homeassistant/components/uptime/translations/pt-BR.json index d3dddae8233c84..ae89c128e08630 100644 --- a/homeassistant/components/uptime/translations/pt-BR.json +++ b/homeassistant/components/uptime/translations/pt-BR.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A configura\u00e7\u00e3o do Uptime usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML do Uptime foi removida" + } + }, "title": "Tempo de atividade" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/sv.json b/homeassistant/components/uptime/translations/sv.json index 0d9e03ec575095..48ca71741a52e9 100644 --- a/homeassistant/components/uptime/translations/sv.json +++ b/homeassistant/components/uptime/translations/sv.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Uptime med YAML har tagits bort. \n\n Din befintliga YAML-konfiguration anv\u00e4nds inte av Home Assistant. \n\n Ta bort YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Uptime YAML-konfigurationen har tagits bort" + } + }, "title": "Upptid" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/tr.json b/homeassistant/components/uptime/translations/tr.json index ed090a383984b5..72132e5e97924c 100644 --- a/homeassistant/components/uptime/translations/tr.json +++ b/homeassistant/components/uptime/translations/tr.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "YAML kullanarak \u00c7al\u0131\u015fma S\u00fcresini yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lm\u0131yor. \n\n YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Uptime YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" + } + }, "title": "\u00c7al\u0131\u015fma S\u00fcresi" } \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/nb.json b/homeassistant/components/uptimerobot/translations/nb.json new file mode 100644 index 00000000000000..d00b0b5126750f --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/no.json b/homeassistant/components/uptimerobot/translations/no.json index df7a7f8045a8a4..e3cbe428b645dd 100644 --- a/homeassistant/components/uptimerobot/translations/no.json +++ b/homeassistant/components/uptimerobot/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/uptimerobot/translations/zh-Hans.json b/homeassistant/components/uptimerobot/translations/zh-Hans.json index d680c09e967d34..f67afb7a2cb5c5 100644 --- a/homeassistant/components/uptimerobot/translations/zh-Hans.json +++ b/homeassistant/components/uptimerobot/translations/zh-Hans.json @@ -17,7 +17,8 @@ "data": { "api_key": "API \u5bc6\u94a5" }, - "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"", + "title": "\u91cd\u65b0\u8ba4\u8bc1\u96c6\u6210" }, "user": { "data": { diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 55ffe111a730b6..7c0355fa24cb3b 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -315,7 +315,7 @@ async def async_request_scan(self) -> None: async def websocket_usb_scan( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Scan for new usb devices.""" usb_discovery: USBDiscovery = hass.data[DOMAIN] diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 9f1b30181862d1..5e14b795dae20c 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -16,7 +16,7 @@ from .const import DOMAIN -class ValloxBinarySensor(ValloxEntity, BinarySensorEntity): +class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): """Representation of a Vallox binary sensor.""" entity_description: ValloxBinarySensorEntityDescription @@ -56,7 +56,7 @@ class ValloxBinarySensorEntityDescription( """Describes Vallox binary sensor entity.""" -SENSORS: tuple[ValloxBinarySensorEntityDescription, ...] = ( +BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( ValloxBinarySensorEntityDescription( key="post_heater", name="Post heater", @@ -77,7 +77,7 @@ async def async_setup_entry( async_add_entities( [ - ValloxBinarySensor(data["name"], data["coordinator"], description) - for description in SENSORS + ValloxBinarySensorEntity(data["name"], data["coordinator"], description) + for description in BINARY_SENSOR_ENTITIES ] ) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index be496bbf8996e3..be713e34e25148 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -70,7 +70,7 @@ async def async_setup_entry( client = data["client"] client.set_settable_address(METRIC_KEY_MODE, int) - device = ValloxFan( + device = ValloxFanEntity( data["name"], client, data["coordinator"], @@ -79,7 +79,7 @@ async def async_setup_entry( async_add_entities([device]) -class ValloxFan(ValloxEntity, FanEntity): +class ValloxFanEntity(ValloxEntity, FanEntity): """Representation of the fan.""" _attr_supported_features = FanEntityFeature.PRESET_MODE diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 2e00452fdf2841..c349107a3f381f 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -156,7 +157,7 @@ class ValloxSensorEntityDescription(SensorEntityDescription): metric_key="A_CYC_EXTR_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_type=ValloxFanSpeedSensor, entity_registry_enabled_default=False, ), @@ -166,7 +167,7 @@ class ValloxSensorEntityDescription(SensorEntityDescription): metric_key="A_CYC_SUPP_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_type=ValloxFanSpeedSensor, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/vallox/translations/nb.json b/homeassistant/components/vallox/translations/nb.json new file mode 100644 index 00000000000000..d00b0b5126750f --- /dev/null +++ b/homeassistant/components/vallox/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index d4229066eff26b..118d04d3c1ba9e 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_DELAY, CONF_NAME +from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,6 @@ ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" -ATTRIBUTION = "Data provided by Västtrafik" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -83,6 +82,8 @@ def setup_platform( class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" + _attr_attribution = "Data provided by Västtrafik" + def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" self._planner = planner @@ -158,7 +159,6 @@ def update(self) -> None: params = { ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_DIRECTION: departure.get("direction"), ATTR_LINE: departure.get("sname"), ATTR_TRACK: departure.get("track"), diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index eeeee2f9716799..2ac751e283d03e 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -1,9 +1,11 @@ """Support for Velbus devices.""" from __future__ import annotations +from contextlib import suppress import logging +import os +import shutil -from velbusaio.channels import Channel as VelbusChannel from velbusaio.controller import Velbus import voluptuous as vol @@ -13,12 +15,13 @@ from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.storage import STORAGE_DIR from .const import ( CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, + SERVICE_CLEAR_CACHE, SERVICE_SCAN, SERVICE_SET_MEMO_TEXT, SERVICE_SYNC, @@ -66,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller = Velbus( entry.data[CONF_PORT], - cache_dir=hass.config.path(".storage/velbuscache/"), + cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), ) hass.data[DOMAIN][entry.entry_id] = {} hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller @@ -132,11 +135,47 @@ async def set_memo_text(call: ServiceCall) -> None: ), ) + async def clear_cache(call: ServiceCall) -> None: + """Handle a clear cache service call.""" + # clear the cache + with suppress(FileNotFoundError): + if call.data[CONF_ADDRESS]: + await hass.async_add_executor_job( + os.unlink, + hass.config.path( + STORAGE_DIR, + f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + ), + ) + else: + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path( + STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" + ), + ) + # call a scan to repopulate + await scan(call) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_CACHE, + clear_cache, + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + ) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Remove the velbus connection.""" + """Unload (close) the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() hass.data[DOMAIN].pop(entry.entry_id) @@ -145,33 +184,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_remove(DOMAIN, SERVICE_SCAN) hass.services.async_remove(DOMAIN, SERVICE_SYNC) hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) + hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) return unload_ok -class VelbusEntity(Entity): - """Representation of a Velbus entity.""" - - _attr_should_poll: bool = False - - def __init__(self, channel: VelbusChannel) -> None: - """Initialize a Velbus entity.""" - self._channel = channel - self._attr_name = channel.get_name() - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, str(channel.get_module_address())), - }, - manufacturer="Velleman", - model=channel.get_module_type_name(), - name=channel.get_full_name(), - sw_version=channel.get_module_sw_version(), - ) - serial = channel.get_module_serial() or str(channel.get_module_address()) - self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" - - async def async_added_to_hass(self) -> None: - """Add listener for state changes.""" - self._channel.on_status_update(self._on_update) - - async def _on_update(self) -> None: - self.async_write_ha_state() +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove the velbus entry, so we also have to cleanup the cache dir.""" + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), + ) diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 8c67520dd9adfd..ef0cef938b18f7 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index 189cfb495e46b3..5f76f7bba9800f 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index a6549f0262c7f1..76eb3e30fa0db5 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN, PRESET_MODES +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 7c41274f11c135..a3949646598d3b 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -16,6 +16,7 @@ SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" SERVICE_SET_MEMO_TEXT: Final = "set_memo_text" +SERVICE_CLEAR_CACHE: Final = "clear_cache" PRESET_MODES: Final = { PRESET_ECO: "safe", diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 6bd1629d3a323a..009c4fadfb9c32 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( @@ -38,7 +38,7 @@ class VelbusCover(VelbusEntity, CoverEntity): _channel: VelbusBlind def __init__(self, channel: VelbusBlind) -> None: - """Initialize the dimmer.""" + """Initialize the cover.""" super().__init__(channel) if self._channel.support_position(): self._attr_supported_features = ( @@ -59,6 +59,16 @@ def is_closed(self) -> bool | None: """Return if the cover is closed.""" return self._channel.is_closed() + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._channel.is_opening() + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._channel.is_closing() + @property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -66,7 +76,10 @@ def current_cover_position(self) -> int | None: None is unknown, 0 is closed, 100 is fully open Velbus: 100 = closed, 0 = open """ - return 100 - self._channel.get_position() + pos = self._channel.get_position() + if pos is not None: + return 100 - pos + return None async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py new file mode 100644 index 00000000000000..13ecb7febab5fa --- /dev/null +++ b/homeassistant/components/velbus/entity.py @@ -0,0 +1,37 @@ +"""Support for Velbus devices.""" +from __future__ import annotations + +from velbusaio.channels import Channel as VelbusChannel + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class VelbusEntity(Entity): + """Representation of a Velbus entity.""" + + _attr_should_poll: bool = False + + def __init__(self, channel: VelbusChannel) -> None: + """Initialize a Velbus entity.""" + self._channel = channel + self._attr_name = channel.get_name() + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, str(channel.get_module_address())), + }, + manufacturer="Velleman", + model=channel.get_module_type_name(), + name=channel.get_full_name(), + sw_version=channel.get_module_sw_version(), + ) + serial = channel.get_module_serial() or str(channel.get_module_address()) + self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + + async def async_added_to_hass(self) -> None: + """Add listener for state changes.""" + self._channel.on_status_update(self._on_update) + + async def _on_update(self) -> None: + self.async_write_ha_state() diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index f562e250892b4c..b5b106b6f9fd59 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -24,8 +24,8 @@ from homeassistant.helpers.entity import Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1a5d78d24d6801..86e67ca7767fa1 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,10 +2,11 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.10.2"], + "requirements": ["velbus-aio==2022.10.4"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "dependencies": ["usb"], + "integration_type": "hub", "iot_class": "local_push", "usb": [ { diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index a0bd9b6c1732a4..0805ae2699ac17 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 6dbf5e8cb4723d..32cda00f708301 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -24,6 +24,29 @@ scan: selector: text: +clear_cache: + name: Clear cache + description: Clears the velbuscache and then starts a new scan + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: "" + selector: + text: + address: + name: Address + description: > + The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) + The decimal addresses are displayed in front of the modules listed at the integration page. + required: false + selector: + number: + min: 1 + max: 254 + set_memo_text: name: Set memo text description: > diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index c3c4c8a586360a..6de8373d3fc6a8 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/venstar/translations/nb.json b/homeassistant/components/venstar/translations/nb.json index 847c45368fd80b..fc3d4c4023c664 100644 --- a/homeassistant/components/venstar/translations/nb.json +++ b/homeassistant/components/venstar/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 820b8a20f14546..cc4f90bb92bc71 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -11,5 +11,6 @@ } ], "iot_class": "cloud_polling", + "integration_type": "hub", "loggers": ["verisure"] } diff --git a/homeassistant/components/verisure/translations/bg.json b/homeassistant/components/verisure/translations/bg.json index f5447e1d865077..927c79f2674dc1 100644 --- a/homeassistant/components/verisure/translations/bg.json +++ b/homeassistant/components/verisure/translations/bg.json @@ -13,10 +13,20 @@ "code": "\u041a\u043e\u0434 \u0437\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430" } }, + "reauth_confirm": { + "data": { + "email": "\u0418\u043c\u0435\u0439\u043b" + } + }, "reauth_mfa": { "data": { "code": "\u041a\u043e\u0434 \u0437\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430" } + }, + "user": { + "data": { + "email": "\u0418\u043c\u0435\u0439\u043b" + } } } } diff --git a/homeassistant/components/verisure/translations/nb.json b/homeassistant/components/verisure/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/verisure/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/verisure/translations/no.json b/homeassistant/components/verisure/translations/no.json index 195f8aadd3d062..4c29acd5a32efb 100644 --- a/homeassistant/components/verisure/translations/no.json +++ b/homeassistant/components/verisure/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/vesync/translations/bg.json b/homeassistant/components/vesync/translations/bg.json index c435a669d5a45d..56cdd7e1d91f7c 100644 --- a/homeassistant/components/vesync/translations/bg.json +++ b/homeassistant/components/vesync/translations/bg.json @@ -7,7 +7,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "E-mail \u0430\u0434\u0440\u0435\u0441" + "username": "\u0418\u043c\u0435\u0439\u043b" }, "title": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430" } diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index acb6ffa647e516..bdc8909da0f2c0 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, TIME_MINUTES +from homeassistant.const import TIME_MINUTES from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -20,8 +20,6 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Powered by ViaggiaTreno Data" - VIAGGIATRENO_ENDPOINT = ( "http://www.viaggiatreno.it/infomobilita/" "resteasy/viaggiatreno/andamentoTreno/" @@ -96,6 +94,8 @@ async def async_http_request(hass, uri): class ViaggiaTrenoSensor(SensorEntity): """Implementation of a ViaggiaTreno sensor.""" + _attr_attribution = "Powered by ViaggiaTreno Data" + def __init__(self, train_id, station_id, name): """Initialize the sensor.""" self._state = None @@ -132,7 +132,6 @@ def native_unit_of_measurement(self): @property def extra_state_attributes(self): """Return extra attributes.""" - self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION return self._attributes @staticmethod diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index e1deef0df00dc4..86f2f3931387ef 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -86,6 +86,38 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="primary_circuit_supply_temperature", + name="Primary Circuit Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperaturePrimaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="primary_circuit_return_temperature", + name="Primary Circuit Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperaturePrimaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="secondary_circuit_supply_temperature", + name="Secondary Circuit Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperatureSecondaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="secondary_circuit_return_temperature", + name="Secondary Circuit Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperatureSecondaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_out_temperature", name="Hot Water Out Temperature", @@ -94,6 +126,22 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="hotwater_max_temperature", + name="Hot Water Max Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="hotwater_min_temperature", + name="Hot Water Min Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", name="Hot water gas consumption today", diff --git a/homeassistant/components/vicare/translations/bg.json b/homeassistant/components/vicare/translations/bg.json index 242339c3815433..e6c4900778838a 100644 --- a/homeassistant/components/vicare/translations/bg.json +++ b/homeassistant/components/vicare/translations/bg.json @@ -13,7 +13,7 @@ "data": { "client_id": "API \u043a\u043b\u044e\u0447", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } } } diff --git a/homeassistant/components/vicare/translations/nb.json b/homeassistant/components/vicare/translations/nb.json new file mode 100644 index 00000000000000..11a4fc139b877d --- /dev/null +++ b/homeassistant/components/vicare/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/translations/nb.json b/homeassistant/components/vilfo/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/vilfo/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 5b534f861cc37f..3fe0ac45885034 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,5 +8,6 @@ "zeroconf": ["_viziocast._tcp.local."], "quality_scale": "platinum", "iot_class": "local_polling", - "loggers": ["pyvizio"] + "loggers": ["pyvizio"], + "integration_type": "hub" } diff --git a/homeassistant/components/vlc_telnet/translations/nb.json b/homeassistant/components/vlc_telnet/translations/nb.json new file mode 100644 index 00000000000000..d00b0b5126750f --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vlc_telnet/translations/no.json b/homeassistant/components/vlc_telnet/translations/no.json index 9becf574700c81..d3e8a3005d70a8 100644 --- a/homeassistant/components/vlc_telnet/translations/no.json +++ b/homeassistant/components/vlc_telnet/translations/no.json @@ -4,7 +4,7 @@ "already_configured": "Tjenesten er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/volumio/translations/nb.json b/homeassistant/components/volumio/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/volumio/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 5a24712b7a364a..65bc6c1cfbe398 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( @@ -314,6 +315,16 @@ def assumed_state(self): """Return true if unable to access real state of entity.""" return True + @property + def device_info(self) -> DeviceInfo: + """Return a inique set of attributes for each vehicle.""" + return DeviceInfo( + identifiers={(DOMAIN, self.vehicle.vin)}, + name=self._vehicle_name, + model=self.vehicle.vehicle_type, + manufacturer="Volvo", + ) + @property def extra_state_attributes(self): """Return device specific state attributes.""" diff --git a/homeassistant/components/volvooncall/translations/bg.json b/homeassistant/components/volvooncall/translations/bg.json index 598cf3c837ff76..62a0a14568db1f 100644 --- a/homeassistant/components/volvooncall/translations/bg.json +++ b/homeassistant/components/volvooncall/translations/bg.json @@ -15,6 +15,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "region": "\u0420\u0435\u0433\u0438\u043e\u043d", "scandinavian_miles": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0441\u043a\u0430\u043d\u0434\u0438\u043d\u0430\u0432\u0441\u043a\u0438 \u043c\u0438\u043b\u0438", + "unit_system": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u0435\u0434\u0438\u043d\u0438\u0446\u0438", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } diff --git a/homeassistant/components/volvooncall/translations/et.json b/homeassistant/components/volvooncall/translations/et.json index 740e0bbc68cfc5..9f2912b5d5345e 100644 --- a/homeassistant/components/volvooncall/translations/et.json +++ b/homeassistant/components/volvooncall/translations/et.json @@ -15,6 +15,7 @@ "password": "Salas\u00f5na", "region": "Piirkond", "scandinavian_miles": "Kasuta Scandinavian Miles", + "unit_system": "\u00dchikute s\u00fcsteem", "username": "Kasutajanimi" } } diff --git a/homeassistant/components/volvooncall/translations/he.json b/homeassistant/components/volvooncall/translations/he.json new file mode 100644 index 00000000000000..6f2cdbf82e128d --- /dev/null +++ b/homeassistant/components/volvooncall/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/translations/ja.json b/homeassistant/components/volvooncall/translations/ja.json index 0f56a700da00c7..4127b96671026a 100644 --- a/homeassistant/components/volvooncall/translations/ja.json +++ b/homeassistant/components/volvooncall/translations/ja.json @@ -15,6 +15,7 @@ "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", "region": "\u30ea\u30fc\u30b8\u30e7\u30f3", "scandinavian_miles": "\u30b9\u30ab\u30f3\u30b8\u30ca\u30d3\u30a2\u30de\u30a4\u30eb(Scandinavian Miles)\u3092\u4f7f\u7528\u3059\u308b", + "unit_system": "\u5358\u4f4d\u30b7\u30b9\u30c6\u30e0", "username": "\u30e6\u30fc\u30b6\u30fc\u540d" } } diff --git a/homeassistant/components/volvooncall/translations/nb.json b/homeassistant/components/volvooncall/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/volvooncall/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/translations/no.json b/homeassistant/components/volvooncall/translations/no.json index 2d60c5983fc03f..48639f07b67fd9 100644 --- a/homeassistant/components/volvooncall/translations/no.json +++ b/homeassistant/components/volvooncall/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/volvooncall/translations/sv.json b/homeassistant/components/volvooncall/translations/sv.json index 03658c137df3b0..48d56656c6ad6d 100644 --- a/homeassistant/components/volvooncall/translations/sv.json +++ b/homeassistant/components/volvooncall/translations/sv.json @@ -15,6 +15,7 @@ "password": "L\u00f6senord", "region": "Region", "scandinavian_miles": "Anv\u00e4nd Skandinaviska mil", + "unit_system": "Enhetssystem", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/volvooncall/translations/tr.json b/homeassistant/components/volvooncall/translations/tr.json index 0b56c9b67b62a0..4db9497008692a 100644 --- a/homeassistant/components/volvooncall/translations/tr.json +++ b/homeassistant/components/volvooncall/translations/tr.json @@ -15,6 +15,7 @@ "password": "Parola", "region": "B\u00f6lge", "scandinavian_miles": "\u0130skandinav Millerini Kullan\u0131n", + "unit_system": "Birim Sistemi", "username": "Kullan\u0131c\u0131 Ad\u0131" } } diff --git a/homeassistant/components/vulcan/translations/bg.json b/homeassistant/components/vulcan/translations/bg.json index f99cd3cca145e7..db0b6604e3f880 100644 --- a/homeassistant/components/vulcan/translations/bg.json +++ b/homeassistant/components/vulcan/translations/bg.json @@ -8,6 +8,11 @@ "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "auth": { + "data": { + "region": "\u0421\u0438\u043c\u0432\u043e\u043b" + } + }, "select_saved_credentials": { "data": { "credentials": "\u0412\u0445\u043e\u0434" diff --git a/homeassistant/components/wallbox/translations/nb.json b/homeassistant/components/wallbox/translations/nb.json index 847c45368fd80b..fc3d4c4023c664 100644 --- a/homeassistant/components/wallbox/translations/nb.json +++ b/homeassistant/components/wallbox/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/wallbox/translations/no.json b/homeassistant/components/wallbox/translations/no.json index 498362fad1db73..c4cf220e5e558c 100644 --- a/homeassistant/components/wallbox/translations/no.json +++ b/homeassistant/components/wallbox/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/water_heater/translations/bg.json b/homeassistant/components/water_heater/translations/bg.json index b751234eaea9ea..c80c861f5ddf53 100644 --- a/homeassistant/components/water_heater/translations/bg.json +++ b/homeassistant/components/water_heater/translations/bg.json @@ -4,5 +4,10 @@ "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}", "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}" } + }, + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/diagnostics.py b/homeassistant/components/watttime/diagnostics.py index 080c7c37b07bb1..2808e8e3c35094 100644 --- a/homeassistant/components/watttime/diagnostics.py +++ b/homeassistant/components/watttime/diagnostics.py @@ -9,17 +9,25 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_UNIQUE_ID, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN + +CONF_TITLE = "title" TO_REDACT = { + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, CONF_USERNAME, } @@ -32,10 +40,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { - "entry": { - "data": dict(entry.data), - "options": dict(entry.options), - }, + "entry": entry.as_dict(), "data": coordinator.data, }, TO_REDACT, diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json index 1f233b5e1056ec..b661b968373e14 100644 --- a/homeassistant/components/watttime/manifest.json +++ b/homeassistant/components/watttime/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aiowatttime==0.1.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["aiowatttime"] + "loggers": ["aiowatttime"], + "integration_type": "service" } diff --git a/homeassistant/components/watttime/translations/nb.json b/homeassistant/components/watttime/translations/nb.json index 847c45368fd80b..fc3d4c4023c664 100644 --- a/homeassistant/components/watttime/translations/nb.json +++ b/homeassistant/components/watttime/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/watttime/translations/no.json b/homeassistant/components/watttime/translations/no.json index 19ec82e863cdad..5b94a79bad2297 100644 --- a/homeassistant/components/watttime/translations/no.json +++ b/homeassistant/components/watttime/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 4e82af5119c990..806672b3608aa8 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -2,24 +2,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get, -) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" - if entry.unique_id is not None: - hass.config_entries.async_update_entry(entry, unique_id=None) - - ent_reg = async_get(hass) - for entity in async_entries_for_config_entry(ent_reg, entry.entry_id): - ent_reg.async_update_entity(entity.entity_id, new_unique_id=entry.entry_id) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 45aeada2a7a5da..b26732e4cb1faf 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -5,8 +5,10 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_REGION -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( CONF_AVOID_FERRIES, @@ -20,7 +22,9 @@ CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_NAME, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, REGIONS, UNITS, VEHICLE_TYPES, @@ -28,6 +32,14 @@ from .helpers import is_valid_config_entry +def default_options(hass: HomeAssistant) -> dict[str, str | bool]: + """Get the default options.""" + defaults = DEFAULT_OPTIONS.copy() + if hass.config.units is US_CUSTOMARY_SYSTEM: + defaults[CONF_UNITS] = IMPERIAL_UNITS + return defaults + + class WazeOptionsFlow(config_entries.OptionsFlow): """Handle an options flow for Waze Travel Time.""" @@ -35,12 +47,12 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize waze options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Handle the initial step.""" if user_input is not None: return self.async_create_entry( title="", - data={k: v for k, v in user_input.items() if v not in (None, "")}, + data=user_input, ) return self.async_show_form( @@ -99,7 +111,7 @@ def async_get_options_flow( """Get the options flow for this handler.""" return WazeOptionsFlow(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} user_input = user_input or {} @@ -115,6 +127,7 @@ async def async_step_user(self, user_input=None): return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), data=user_input, + options=default_options(self.hass), ) # If we get here, it's because we couldn't connect @@ -134,5 +147,3 @@ async def async_step_user(self, user_input=None): ), errors=errors, ) - - async_step_import = async_step_user diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 37278543dfb6b1..1121519f8cd91a 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -1,5 +1,5 @@ """Constants for waze_travel_time.""" -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from __future__ import annotations DOMAIN = "waze_travel_time" @@ -21,7 +21,18 @@ DEFAULT_AVOID_SUBSCRIPTION_ROADS = False DEFAULT_AVOID_FERRIES = False -UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] +IMPERIAL_UNITS = "imperial" +METRIC_UNITS = "metric" +UNITS = [METRIC_UNITS, IMPERIAL_UNITS] REGIONS = ["US", "NA", "EU", "IL", "AU"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] + +DEFAULT_OPTIONS: dict[str, str | bool] = { + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, +} diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index 67d8b5674b282f..8468bb8ea9a6a0 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -1,8 +1,12 @@ """Helpers for Waze Travel Time integration.""" +import logging + from WazeRouteCalculator import WazeRouteCalculator, WRCError from homeassistant.helpers.location import find_coordinates +_LOGGER = logging.getLogger(__name__) + def is_valid_config_entry(hass, origin, destination, region): """Return whether the config entry data is valid.""" @@ -10,6 +14,7 @@ def is_valid_config_entry(hass, origin, destination, region): destination = find_coordinates(hass, destination) try: WazeRouteCalculator(origin, destination, region).calc_all_routes_info() - except WRCError: + except WRCError as error: + _LOGGER.error("Error trying to validate entry: %s", error) return False return True diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 153ada113498b9..c8d3e308435aa9 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -13,12 +13,11 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, - CONF_UNIT_SYSTEM_IMPERIAL, EVENT_HOMEASSISTANT_STARTED, LENGTH_KILOMETERS, + LENGTH_MILES, TIME_MINUTES, ) from homeassistant.core import CoreState, HomeAssistant @@ -26,7 +25,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter from .const import ( CONF_AVOID_FERRIES, @@ -39,13 +38,9 @@ CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, - DEFAULT_AVOID_FERRIES, - DEFAULT_AVOID_SUBSCRIPTION_ROADS, - DEFAULT_AVOID_TOLL_ROADS, DEFAULT_NAME, - DEFAULT_REALTIME, - DEFAULT_VEHICLE_TYPE, DOMAIN, + IMPERIAL_UNITS, ) _LOGGER = logging.getLogger(__name__) @@ -59,37 +54,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" - defaults = { - CONF_REALTIME: DEFAULT_REALTIME, - CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, - CONF_UNITS: hass.config.units.name, - CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, - } - - if not config_entry.options: - new_data = config_entry.data.copy() - options = {} - for key in ( - CONF_INCL_FILTER, - CONF_EXCL_FILTER, - CONF_REALTIME, - CONF_VEHICLE_TYPE, - CONF_AVOID_TOLL_ROADS, - CONF_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_FERRIES, - CONF_UNITS, - ): - if key in new_data: - options[key] = new_data.pop(key) - elif key in defaults: - options[key] = defaults[key] - - hass.config_entries.async_update_entry( - config_entry, data=new_data, options=options - ) - destination = config_entry.data[CONF_DESTINATION] origin = config_entry.data[CONF_ORIGIN] region = config_entry.data[CONF_REGION] @@ -110,6 +74,7 @@ async def async_setup_entry( class WazeTravelTime(SensorEntity): """Representation of a Waze travel time sensor.""" + _attr_attribution = "Powered by Waze" _attr_native_unit_of_measurement = TIME_MINUTES _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT @@ -154,7 +119,6 @@ def extra_state_attributes(self) -> dict | None: return None return { - ATTR_ATTRIBUTION: "Powered by Waze", "duration": self._waze_data.duration, "distance": self._waze_data.distance, "route": self._waze_data.route, @@ -221,14 +185,14 @@ def update(self): ) routes = params.calc_all_routes_info(real_time=realtime) - if incl_filter is not None: + if incl_filter not in {None, ""}: routes = { k: v for k, v in routes.items() if incl_filter.lower() in k.lower() } - if excl_filter is not None: + if excl_filter not in {None, ""}: routes = { k: v for k, v in routes.items() @@ -243,9 +207,11 @@ def update(self): self.duration, distance = routes[route] - if units == CONF_UNIT_SYSTEM_IMPERIAL: + if units == IMPERIAL_UNITS: # Convert to miles. - self.distance = IMPERIAL_SYSTEM.length(distance, LENGTH_KILOMETERS) + self.distance = DistanceConverter.convert( + distance, LENGTH_KILOMETERS, LENGTH_MILES + ) else: self.distance = distance diff --git a/homeassistant/components/waze_travel_time/translations/bg.json b/homeassistant/components/waze_travel_time/translations/bg.json index fb5df0326711c5..5b18b5ba0219c5 100644 --- a/homeassistant/components/waze_travel_time/translations/bg.json +++ b/homeassistant/components/waze_travel_time/translations/bg.json @@ -11,5 +11,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "units": "\u0415\u0434\u0438\u043d\u0438\u0446\u0438" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 2014c5b4eedbda..8ffced6d5d22a2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -44,6 +44,7 @@ SpeedConverter, TemperatureConverter, ) +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -419,7 +420,9 @@ def _default_pressure_unit(self) -> str: Should not be set by integrations. """ - return PRESSURE_HPA if self.hass.config.units.is_metric else PRESSURE_INHG + return ( + PRESSURE_HPA if self.hass.config.units is METRIC_SYSTEM else PRESSURE_INHG + ) @final @property @@ -483,7 +486,7 @@ def _default_wind_speed_unit(self) -> str: """ return ( SPEED_KILOMETERS_PER_HOUR - if self.hass.config.units.is_metric + if self.hass.config.units is METRIC_SYSTEM else SPEED_MILES_PER_HOUR ) diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 449de006bf934c..bd80f38b832446 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -168,7 +168,11 @@ async def _handle(self, request: Request, webhook_id: str) -> Response: } ) @callback -def websocket_list(hass, connection, msg): +def websocket_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Return a list of webhooks.""" handlers = hass.data.setdefault(DOMAIN, {}) result = [ @@ -195,7 +199,11 @@ def websocket_list(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_handle(hass, connection, msg): +async def websocket_handle( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle an incoming webhook via the WS API.""" request = MockRequest( content=msg["body"].encode("utf-8"), diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py new file mode 100644 index 00000000000000..ce62f51b540e7a --- /dev/null +++ b/homeassistant/components/webostv/diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics support for LG webOS Smart TV.""" +from __future__ import annotations + +from typing import Any + +from aiowebostv import WebOsClient + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DATA_CONFIG_ENTRY, DOMAIN + +TO_REDACT = { + CONF_CLIENT_SECRET, + CONF_UNIQUE_ID, + CONF_HOST, + "device_id", + "deviceUUID", + "icon", + "largeIcon", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].client + + client_data = { + "is_registered": client.is_registered(), + "is_connected": client.is_connected(), + "current_app_id": client.current_app_id, + "current_channel": client.current_channel, + "apps": client.apps, + "inputs": client.inputs, + "system_info": client.system_info, + "software_info": client.software_info, + "hello_info": client.hello_info, + "sound_output": client.sound_output, + "is_on": client.is_on, + } + + return async_redact_data( + { + "entry": entry.as_dict(), + "client": client_data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a78099e60655ec..19ec505e449125 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, - SIGNAL_BOOTSTRAP_INTEGRATONS, + SIGNAL_BOOTSTRAP_INTEGRATIONS, ) from homeassistant.core import Context, Event, HomeAssistant, State, callback from homeassistant.exceptions import ( @@ -21,7 +21,6 @@ TemplateError, Unauthorized, ) -from homeassistant.generated import supported_brands from homeassistant.helpers import config_validation as cv, entity, template from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( @@ -74,7 +73,6 @@ def async_register_commands( async_reg(hass, handle_unsubscribe_events) async_reg(hass, handle_validate_config) async_reg(hass, handle_subscribe_entities) - async_reg(hass, handle_supported_brands) async_reg(hass, handle_supported_features) async_reg(hass, handle_integration_descriptions) @@ -151,7 +149,7 @@ def forward_bootstrap_integrations(message: dict[str, Any]) -> None: connection.send_message(messages.event_message(msg["id"], message)) connection.subscriptions[msg["id"]] = async_dispatcher_connect( - hass, SIGNAL_BOOTSTRAP_INTEGRATONS, forward_bootstrap_integrations + hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, forward_bootstrap_integrations ) connection.send_result(msg["id"]) @@ -705,31 +703,6 @@ async def handle_validate_config( connection.send_result(msg["id"], result) -@decorators.websocket_command( - { - vol.Required("type"): "supported_brands", - } -) -@decorators.async_response -async def handle_supported_brands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Handle supported brands command.""" - data = {} - - ints_or_excs = await async_get_integrations( - hass, supported_brands.HAS_SUPPORTED_BRANDS - ) - for int_or_exc in ints_or_excs.values(): - if isinstance(int_or_exc, Exception): - raise int_or_exc - # Happens if a custom component without supported brands overrides a built-in one with supported brands - if "supported_brands" not in int_or_exc.manifest: - continue - data[int_or_exc.domain] = int_or_exc.manifest["supported_brands"] - connection.send_result(msg["id"], data) - - @callback @decorators.websocket_command( { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index c344e1c6a9fd4e..ab4dda845db9f7 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User +from homeassistant.components.http import current_request from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized @@ -137,6 +138,13 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: err_message = "Unknown error" log_handler = self.logger.exception - log_handler("Error handling message: %s (%s)", err_message, code) - self.send_message(messages.error_message(msg["id"], code, err_message)) + + if code: + err_message += f" ({code})" + if request := current_request.get(): + err_message += f" from {request.remote}" + if user_agent := request.headers.get("user-agent"): + err_message += f" ({user_agent})" + + log_handler("Error handling message: %s", err_message) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 7336fa1c0d22a3..23c8fddd56ca45 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -155,7 +155,8 @@ def _check_write_peak(self, _utc_time: dt.datetime) -> None: return self._logger.error( - "Client unable to keep up with pending messages. Stayed over %s for %s seconds", + "Client unable to keep up with pending messages. Stayed over %s for %s seconds. " + "The system's load is too high or an integration is misbehaving", PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, ) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 3ff0f115a04567..2767d44032cae1 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -76,8 +76,7 @@ def async_update_lights() -> None: known_light_ids.add(light_id) new_lights.append(WemoLight(coordinator, light)) - if new_lights: - async_add_entities(new_lights) + async_add_entities(new_lights) async_update_lights() config_entry.async_on_unload(coordinator.async_add_listener(async_update_lights)) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 5486a192787363..b324ba060ea161 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -14,8 +14,5 @@ }, "codeowners": ["@esev"], "iot_class": "local_push", - "loggers": ["pywemo"], - "supported_brands": { - "digital_loggers": "Digital Loggers" - } + "loggers": ["pywemo"] } diff --git a/homeassistant/components/whirlpool/translations/nb.json b/homeassistant/components/whirlpool/translations/nb.json index 847c45368fd80b..fc3d4c4023c664 100644 --- a/homeassistant/components/whirlpool/translations/nb.json +++ b/homeassistant/components/whirlpool/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index 00a2821c8c449f..104b583ea3a7ee 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -6,5 +6,6 @@ "config_flow": true, "codeowners": ["@frenck"], "iot_class": "cloud_polling", + "integration_type": "service", "loggers": ["whois"] } diff --git a/homeassistant/components/wiz/translations/nb.json b/homeassistant/components/wiz/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/wiz/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 74af6cc0793960..2c68401376575a 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -270,5 +270,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDSegmentLight(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 2fc00131fac684..3566349a8537d3 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,5 +7,6 @@ "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", + "integration_type": "device", "iot_class": "local_push" } diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 33b27777c7ecea..eb029a07db73c5 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -1,8 +1,12 @@ """Support for LED numbers.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from functools import partial +from wled import Segment + from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -35,8 +39,20 @@ async def async_setup_entry( update_segments() +@dataclass +class WLEDNumberDescriptionMixin: + """Mixin for WLED number.""" + + value_fn: Callable[[Segment], float | None] + + +@dataclass +class WLEDNumberEntityDescription(NumberEntityDescription, WLEDNumberDescriptionMixin): + """Class describing WLED number entities.""" + + NUMBERS = [ - NumberEntityDescription( + WLEDNumberEntityDescription( key=ATTR_SPEED, name="Speed", icon="mdi:speedometer", @@ -44,14 +60,16 @@ async def async_setup_entry( native_step=1, native_min_value=0, native_max_value=255, + value_fn=lambda segment: segment.speed, ), - NumberEntityDescription( + WLEDNumberEntityDescription( key=ATTR_INTENSITY, name="Intensity", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=255, + value_fn=lambda segment: segment.intensity, ), ] @@ -59,11 +77,13 @@ async def async_setup_entry( class WLEDNumber(WLEDEntity, NumberEntity): """Defines a WLED speed number.""" + entity_description: WLEDNumberEntityDescription + def __init__( self, coordinator: WLEDDataUpdateCoordinator, segment: int, - description: NumberEntityDescription, + description: WLEDNumberEntityDescription, ) -> None: """Initialize WLED .""" super().__init__(coordinator=coordinator) @@ -92,9 +112,8 @@ def available(self) -> bool: @property def native_value(self) -> float | None: """Return the current WLED segment number value.""" - return getattr( - self.coordinator.data.state.segments[self._segment], - self.entity_description.key, + return self.entity_description.value_fn( + self.coordinator.data.state.segments[self._segment] ) @wled_exception_handler @@ -128,5 +147,4 @@ def async_update_segments( for desc in NUMBERS: new_entities.append(WLEDNumber(coordinator, segment_id, desc)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 5b0de05370cf95..badde5515d481e 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -195,5 +195,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDPaletteSelect(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 20b5a6187263d4..9f241756e90ebb 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -215,5 +215,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDReverseSwitch(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/translations/select.pl.json b/homeassistant/components/wled/translations/select.pl.json index 381f2306d260c0..20017c51c4243c 100644 --- a/homeassistant/components/wled/translations/select.pl.json +++ b/homeassistant/components/wled/translations/select.pl.json @@ -3,7 +3,7 @@ "wled__live_override": { "0": "wy\u0142.", "1": "w\u0142.", - "2": "Do czasu ponownego uruchomienia urz\u0105dzenia" + "2": "do czasu ponownego uruchomienia urz\u0105dzenia" } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/nb.json b/homeassistant/components/wolflink/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/wolflink/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ws66i/translations/nb.json b/homeassistant/components/ws66i/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/ws66i/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index ac97d502c5561d..3c262bce82e37c 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -62,8 +62,7 @@ def async_update_friends( ] new_entities = new_entities + current[xuid] - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) # Process deleted favorites, remove them from Home Assistant for xuid in current_ids - new_ids: diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 9cba49d1dcbc29..77d52719c88751 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -64,8 +64,7 @@ def async_update_friends( ] new_entities = new_entities + current[xuid] - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) # Process deleted favorites, remove them from Home Assistant for xuid in current_ids - new_ids: diff --git a/homeassistant/components/xbox/translations/id.json b/homeassistant/components/xbox/translations/id.json index 3df7f3ee8f25d7..e59a8a2989fb87 100644 --- a/homeassistant/components/xbox/translations/id.json +++ b/homeassistant/components/xbox/translations/id.json @@ -16,8 +16,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Xbox di configuration.yaml sedang dihapus di Home Assistant 2022.9. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Xbox dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Xbox di configuration.yaml sedang dihapus di Home Assistant 2022.9. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Xbox dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index 3081f334821ded..07adcbeb5ccf74 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -60,8 +60,7 @@ def setup_platform( continue entities.append(XboxSensor(api, xuid, gamercard, interval)) - if entities: - add_entities(entities, True) + add_entities(entities, True) def get_user_gamercard(api, xuid): diff --git a/homeassistant/components/xiaomi_ble/translations/hu.json b/homeassistant/components/xiaomi_ble/translations/hu.json index 044f970038b8c8..fed82381dcbec9 100644 --- a/homeassistant/components/xiaomi_ble/translations/hu.json +++ b/homeassistant/components/xiaomi_ble/translations/hu.json @@ -41,7 +41,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/xiaomi_ble/translations/no.json b/homeassistant/components/xiaomi_ble/translations/no.json index ff428d248d1697..46a8158cad9eb3 100644 --- a/homeassistant/components/xiaomi_ble/translations/no.json +++ b/homeassistant/components/xiaomi_ble/translations/no.json @@ -7,7 +7,7 @@ "expected_24_characters": "Forventet en heksadesimal bindingsn\u00f8kkel p\u00e5 24 tegn.", "expected_32_characters": "Forventet en heksadesimal bindingsn\u00f8kkel p\u00e5 32 tegn.", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "decryption_failed": "Den oppgitte bindingsn\u00f8kkelen fungerte ikke, sensordata kunne ikke dekrypteres. Vennligst sjekk det og pr\u00f8v igjen.", diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 8719319aec8b46..d3a407d529e829 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -383,10 +383,6 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> assert gateway_id - # For backwards compat - if gateway_id.endswith("-gateway"): - hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"]) - # Connect to gateway gateway = ConnectXiaomiGateway(hass, entry) try: diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 4e2ba24bc0518e..70e6fb5c0b6bed 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -145,18 +145,6 @@ async def async_step_reauth_confirm( return await self.async_step_cloud() return self.async_show_form(step_id="reauth_confirm") - async def async_step_import(self, conf: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - self.host = conf[CONF_HOST] - self.token = conf[CONF_TOKEN] - self.name = conf.get(CONF_NAME) - self.model = conf.get(CONF_MODEL) - - self.context.update( - {"title_placeholders": {"name": f"YAML import {self.host}"}} - ) - return await self.async_step_connect() - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -250,15 +238,22 @@ async def async_step_cloud( errors["base"] = "cloud_login_error" except MiCloudAccessDenied: errors["base"] = "cloud_login_error" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in Miio cloud login") + return self.async_abort(reason="unknown") if errors: return self.async_show_form( step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) - devices_raw = await self.hass.async_add_executor_job( - miio_cloud.get_devices, cloud_country - ) + try: + devices_raw = await self.hass.async_add_executor_job( + miio_cloud.get_devices, cloud_country + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in Miio cloud get devices") + return self.async_abort(reason="unknown") if not devices_raw: errors["base"] = "cloud_no_devices" @@ -353,6 +348,9 @@ async def async_step_connect( except SetupException: if self.model is None: errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in connect Xiaomi device") + return self.async_abort(reason="unknown") device_info = connect_device_class.device_info @@ -386,8 +384,8 @@ async def async_step_connect( data[CONF_CLOUD_USERNAME] = self.cloud_username data[CONF_CLOUD_PASSWORD] = self.cloud_password data[CONF_CLOUD_COUNTRY] = self.cloud_country - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) + if self.hass.config_entries.async_update_entry(existing_entry, data=data): + await self.hass.config_entries.async_reload(existing_entry.entry_id) return self.async_abort(reason="reauth_successful") if self.name is None: diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index c0711a02a36540..0c090a58e02544 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -4,6 +4,7 @@ ROCKROBO_S6, ROCKROBO_S6_MAXV, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ROCKROBO_V1, ) @@ -48,6 +49,8 @@ class SetupException(Exception): # Fan Models MODEL_AIRPURIFIER_4 = "zhimi.airp.mb5" +MODEL_AIRPURIFIER_4_LITE_RMA1 = "zhimi.airpurifier.rma1" +MODEL_AIRPURIFIER_4_LITE_RMB1 = "zhimi.airp.rmb1" MODEL_AIRPURIFIER_4_PRO = "zhimi.airp.vb4" MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" @@ -117,6 +120,8 @@ class SetupException(Exception): MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO, ] @@ -227,6 +232,7 @@ class SetupException(Exception): ROCKROBO_S6_MAXV, ROCKROBO_S6_PURE, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ROBOROCK_GENERIC, ROCKROBO_GENERIC, ] @@ -238,9 +244,11 @@ class SetupException(Exception): ROCKROBO_S6_MAXV, ROCKROBO_S6_PURE, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ] MODELS_VACUUM_WITH_SEPARATE_MOP = [ ROCKROBO_S7, + ROCKROBO_S7_MAXV, ] MODELS_AIR_MONITOR = [ @@ -342,6 +350,10 @@ class SetupException(Exception): | FEATURE_SET_LED_BRIGHTNESS ) +FEATURE_FLAGS_AIRPURIFIER_4_LITE = ( + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED_BRIGHTNESS +) + FEATURE_FLAGS_AIRPURIFIER_4 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index ddbd45bff089a0..dbc8c7a66d9c34 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -49,6 +49,7 @@ FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -70,6 +71,8 @@ MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -151,6 +154,7 @@ } PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] +PRESET_MODES_AIRPURIFIER_4_LITE = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_MIOT = ["Auto", "Silent", "Favorite", "Fan"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO_V7 = PRESET_MODES_AIRPURIFIER_PRO @@ -424,6 +428,15 @@ def __init__(self, device, entry, unique_id, coordinator): FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE ) self._speed_count = 3 + elif self._model in [ + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, + ]: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_4_LITE + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT + self._preset_modes = PRESET_MODES_AIRPURIFIER_4_LITE + self._attr_supported_features = FanEntityFeature.PRESET_MODE + self._speed_count = 1 elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 0f1a9dd92aa2e0..4f806c3ed58fe3 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.12"], - "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling", "loggers": ["micloud", "miio"] diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 15cb7175d91e21..7c5439d8d35402 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -32,6 +32,7 @@ FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -65,6 +66,8 @@ MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -242,6 +245,8 @@ class FavoriteLevelValues: MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_AIRPURIFIER_4_LITE_RMA1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, + MODEL_AIRPURIFIER_4_LITE_RMB1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, MODEL_AIRPURIFIER_4: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 69f8dbfad30bd9..118f3cd5c77be7 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -47,6 +47,8 @@ MODEL_AIRHUMIDIFIER_V1, MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3H, + MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, MODEL_AIRPURIFIER_PROH, @@ -72,7 +74,6 @@ class XiaomiMiioSelectDescription(SelectEntityDescription): options_map: dict = field(default_factory=dict) set_method: str = "" set_method_error_message: str = "" - options: tuple = () class AttributeEnumMapping(NamedTuple): @@ -111,6 +112,12 @@ class AttributeEnumMapping(NamedTuple): MODEL_AIRPURIFIER_3H: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) ], + MODEL_AIRPURIFIER_4: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], + MODEL_AIRPURIFIER_4_PRO: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], MODEL_AIRPURIFIER_M1: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierLedBrightness) ], @@ -142,7 +149,7 @@ class AttributeEnumMapping(NamedTuple): set_method_error_message="Setting the display orientation failed.", icon="mdi:tablet", device_class="xiaomi_miio__display_orientation", - options=("forward", "left", "right"), + options=["forward", "left", "right"], entity_category=EntityCategory.CONFIG, ), XiaomiMiioSelectDescription( @@ -153,7 +160,7 @@ class AttributeEnumMapping(NamedTuple): set_method_error_message="Setting the led brightness failed.", icon="mdi:brightness-6", device_class="xiaomi_miio__led_brightness", - options=("bright", "dim", "off"), + options=["bright", "dim", "off"], entity_category=EntityCategory.CONFIG, ), XiaomiMiioSelectDescription( @@ -164,7 +171,7 @@ class AttributeEnumMapping(NamedTuple): set_method_error_message="Setting the ptc level failed.", icon="mdi:fire-circle", device_class="xiaomi_miio__ptc_level", - options=("low", "medium", "high"), + options=["low", "medium", "high"], entity_category=EntityCategory.CONFIG, ), ) @@ -212,7 +219,6 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): def __init__(self, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(device, entry, unique_id, coordinator) - self._attr_options = list(description.options) self.entity_description = description diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index c94e0e371fb2be..56938a4bd344cb 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -63,6 +63,8 @@ MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -411,6 +413,16 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): ATTR_TEMPERATURE, ATTR_USE_TIME, ) +PURIFIER_4_LITE_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_LEFT_TIME, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, + ATTR_USE_TIME, +) PURIFIER_4_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_LEFT_TIME, @@ -528,6 +540,8 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRPURIFIER_3C: PURIFIER_3C_SENSORS, + MODEL_AIRPURIFIER_4_LITE_RMA1: PURIFIER_4_LITE_SENSORS, + MODEL_AIRPURIFIER_4_LITE_RMB1: PURIFIER_4_LITE_SENSORS, MODEL_AIRPURIFIER_4: PURIFIER_4_SENSORS, MODEL_AIRPURIFIER_4_PRO: PURIFIER_4_PRO_SENSORS, MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index e359f54cc5a1f6..ea9e1712697e8d 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -5,7 +5,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "incomplete_info": "Incomplete information to setup device, no host or token supplied.", - "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio." + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index dbe783c6a8706d..2f45ba0adcaf67 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -46,6 +46,7 @@ FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -82,6 +83,8 @@ MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -197,6 +200,8 @@ MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_AIRPURIFIER_4_LITE_RMA1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, + MODEL_AIRPURIFIER_4_LITE_RMB1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, MODEL_AIRPURIFIER_4: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, diff --git a/homeassistant/components/xiaomi_miio/translations/bg.json b/homeassistant/components/xiaomi_miio/translations/bg.json index 2339d5bc8306a3..2ad6a9dda2667b 100644 --- a/homeassistant/components/xiaomi_miio/translations/bg.json +++ b/homeassistant/components/xiaomi_miio/translations/bg.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", - "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" diff --git a/homeassistant/components/xiaomi_miio/translations/ca.json b/homeassistant/components/xiaomi_miio/translations/ca.json index 614238a54b4b65..ff1297080c3d6c 100644 --- a/homeassistant/components/xiaomi_miio/translations/ca.json +++ b/homeassistant/components/xiaomi_miio/translations/ca.json @@ -5,7 +5,8 @@ "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "incomplete_info": "Informaci\u00f3 incompleta per configurar el dispositiu, no s'ha proporcionat cap amfitri\u00f3 o token.", "not_xiaomi_miio": "Xiaomi Miio encara no \u00e9s compatible amb el dispositiu.", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "unknown": "Error inesperat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 70a630f90f39d8..aa5ad47dd4cfbf 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -5,7 +5,8 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "incomplete_info": "Unvollst\u00e4ndige Informationen zur Einrichtung des Ger\u00e4ts, kein Host oder Token geliefert.", "not_xiaomi_miio": "Ger\u00e4t wird (noch) nicht von Xiaomi Miio unterst\u00fctzt.", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index c37be0a7f74176..d24509e0e255d6 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -5,7 +5,8 @@ "already_in_progress": "Configuration flow is already in progress", "incomplete_info": "Incomplete information to setup device, no host or token supplied.", "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 68bdda15a22a60..3173e888df6985 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -5,7 +5,8 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "incomplete_info": "Informaci\u00f3n incompleta para configurar el dispositivo, no se proporcion\u00f3 host ni token.", "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio.", - "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente" + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/xiaomi_miio/translations/et.json b/homeassistant/components/xiaomi_miio/translations/et.json index fef6a73622a754..bfb9de5077e97f 100644 --- a/homeassistant/components/xiaomi_miio/translations/et.json +++ b/homeassistant/components/xiaomi_miio/translations/et.json @@ -5,7 +5,8 @@ "already_in_progress": "Seadistamine on juba k\u00e4imas", "incomplete_info": "Puudulik seadistusteave, hosti v\u00f5i p\u00e4\u00e4suluba pole esitatud.", "not_xiaomi_miio": "Seade ei ole (veel) Xiaomi Miio poolt toetatud.", - "reauth_successful": "Taastuvastamine \u00f5nnestus" + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "unknown": "Ootamatu t\u00f5rge" }, "error": { "cannot_connect": "\u00dchendus nurjus", diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 91c6b7458160c5..1ed0609da1b7e1 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -5,7 +5,8 @@ "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "incomplete_info": "Informations incompl\u00e8tes pour configurer l'appareil, aucun h\u00f4te ou jeton fourni.", "not_xiaomi_miio": "L'appareil n'est pas (encore) pris en charge par Xiaomi Miio.", - "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index 1a21bd9840a571..07cdec38236e94 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -5,7 +5,8 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "incomplete_info": "\u05de\u05d9\u05d3\u05e2 \u05dc\u05d0 \u05e9\u05dc\u05dd \u05dc\u05d4\u05ea\u05e7\u05e0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df, \u05dc\u05d0 \u05e1\u05d5\u05e4\u05e7\u05d5 \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05d0\u05e1\u05d9\u05de\u05d5\u05df.", "not_xiaomi_miio": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05ea\u05de\u05da (\u05e2\u05d3\u05d9\u05d9\u05df) \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5.", - "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", diff --git a/homeassistant/components/xiaomi_miio/translations/hu.json b/homeassistant/components/xiaomi_miio/translations/hu.json index a7536283240091..874483fffb3457 100644 --- a/homeassistant/components/xiaomi_miio/translations/hu.json +++ b/homeassistant/components/xiaomi_miio/translations/hu.json @@ -5,7 +5,8 @@ "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", "incomplete_info": "Az eszk\u00f6z be\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges inform\u00e1ci\u00f3k hi\u00e1nyosak, nincs megadva \u00e1llom\u00e1s vagy token.", "not_xiaomi_miio": "Az eszk\u00f6zt (m\u00e9g) nem t\u00e1mogatja a Xiaomi Miio integr\u00e1ci\u00f3.", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/xiaomi_miio/translations/it.json b/homeassistant/components/xiaomi_miio/translations/it.json index 51b5f108751493..239620dfdc6d58 100644 --- a/homeassistant/components/xiaomi_miio/translations/it.json +++ b/homeassistant/components/xiaomi_miio/translations/it.json @@ -5,7 +5,8 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "incomplete_info": "Informazioni incomplete per configurare il dispositivo, nessun host o token fornito.", "not_xiaomi_miio": "Il dispositivo non \u00e8 (ancora) supportato da Xiaomi Miio.", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/xiaomi_miio/translations/nl.json b/homeassistant/components/xiaomi_miio/translations/nl.json index cc077839808317..07671e1802ee88 100644 --- a/homeassistant/components/xiaomi_miio/translations/nl.json +++ b/homeassistant/components/xiaomi_miio/translations/nl.json @@ -5,7 +5,8 @@ "already_in_progress": "De configuratie is momenteel al bezig", "incomplete_info": "Onvolledige informatie voor het instellen van het apparaat, geen host of token opgegeven.", "not_xiaomi_miio": "Apparaat wordt (nog) niet ondersteund door Xiaomi Miio.", - "reauth_successful": "Herauthenticatie geslaagd" + "reauth_successful": "Herauthenticatie geslaagd", + "unknown": "Onverwachte fout" }, "error": { "cannot_connect": "Kan geen verbinding maken", diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 3d831df207c8aa..8f06bee02233ce 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -5,7 +5,8 @@ "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "incomplete_info": "Ufullstendig informasjon til installasjonsenheten, ingen vert eller token leveres.", "not_xiaomi_miio": "Enheten st\u00f8ttes (enn\u00e5) ikke av Xiaomi Miio.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket", + "unknown": "Uventet feil" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index d0f3cc9a4a83e5..72b031bd6067f7 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -5,7 +5,8 @@ "already_in_progress": "Konfiguracja jest ju\u017c w toku", "incomplete_info": "Niepe\u0142ne informacje do skonfigurowania urz\u0105dzenia, brak nazwy hosta, IP lub tokena.", "not_xiaomi_miio": "Urz\u0105dzenie nie jest (jeszcze) obs\u0142ugiwane przez Xiaomi Miio.", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/xiaomi_miio/translations/pt-BR.json b/homeassistant/components/xiaomi_miio/translations/pt-BR.json index ce67173f9b2839..f12c3637b7eabf 100644 --- a/homeassistant/components/xiaomi_miio/translations/pt-BR.json +++ b/homeassistant/components/xiaomi_miio/translations/pt-BR.json @@ -5,7 +5,8 @@ "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "incomplete_info": "Informa\u00e7\u00f5es incompletas para configurar o dispositivo, nenhum host ou token fornecido.", "not_xiaomi_miio": "O dispositivo (ainda) n\u00e3o \u00e9 suportado pelo Xiaomi Miio.", - "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", + "unknown": "Erro inesperado" }, "error": { "cannot_connect": "Falha ao conectar", diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json index 1432666cb44852..256cf75bbd268a 100644 --- a/homeassistant/components/xiaomi_miio/translations/ru.json +++ b/homeassistant/components/xiaomi_miio/translations/ru.json @@ -5,7 +5,8 @@ "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "incomplete_info": "\u041d\u0435\u043f\u043e\u043b\u043d\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 \u0442\u043e\u043a\u0435\u043d.", "not_xiaomi_miio": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e (\u043f\u043e\u043a\u0430) \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f Xiaomi Miio.", - "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", diff --git a/homeassistant/components/xiaomi_miio/translations/select.pl.json b/homeassistant/components/xiaomi_miio/translations/select.pl.json index 92a1539b9cee80..09197cc33f124f 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.pl.json +++ b/homeassistant/components/xiaomi_miio/translations/select.pl.json @@ -1,9 +1,9 @@ { "state": { "xiaomi_miio__display_orientation": { - "forward": "Do przodu", - "left": "W lewo", - "right": "W prawo" + "forward": "do przodu", + "left": "w lewo", + "right": "w prawo" }, "xiaomi_miio__led_brightness": { "bright": "jasne", @@ -11,9 +11,9 @@ "off": "wy\u0142\u0105czone" }, "xiaomi_miio__ptc_level": { - "high": "Wysoki", - "low": "Niski", - "medium": "\u015aredni" + "high": "wysoki", + "low": "niski", + "medium": "\u015bredni" } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json index e38567ea37cd27..9c3158ac2e9f1f 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json @@ -5,7 +5,8 @@ "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "incomplete_info": "\u6240\u63d0\u4f9b\u4e4b\u88dd\u7f6e\u8cc7\u8a0a\u4e0d\u5b8c\u6574\u3001\u7121\u4e3b\u6a5f\u7aef\u6216\u6b0a\u6756\uff0c\u7121\u6cd5\u8a2d\u5b9a\u88dd\u7f6e\u3002", "not_xiaomi_miio": "\u5c0f\u7c73 Miio \uff08\u5c1a\uff09\u4e0d\u652f\u63f4\u8a72\u88dd\u7f6e\u3002", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 6b4faf32458534..28d60d317e00ff 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -159,6 +159,9 @@ def __init__(self): async def start(self, event): """Start the communication and sends the message.""" + if room: + _LOGGER.debug("Joining room %s", room) + await self.plugin["xep_0045"].join_muc_wait(room, sender, seconds=0) # Sending image and message independently from each other if data: await self.send_file(timeout=timeout) @@ -173,9 +176,6 @@ async def send_file(self, timeout=None): Send XMPP file message using OOB (XEP_0066) and HTTP Upload (XEP_0363) """ - if room: - self.plugin["xep_0045"].join_muc(room, sender) - try: # Uploading with XEP_0363 _LOGGER.debug("Timeout set to %ss", timeout) @@ -335,8 +335,7 @@ def send_text_message(self): """Send a text only message to a room or a recipient.""" try: if room: - _LOGGER.debug("Joining room %s", room) - self.plugin["xep_0045"].join_muc(room, sender) + _LOGGER.debug("Sending message to room %s", room) self.send_message(mto=room, mbody=message, mtype="groupchat") else: for recipient in recipients: diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index 579f61f2d71de7..8299c80ebc2c4e 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 6073bf7a0322c5..7a2b3146265f4d 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -94,6 +94,19 @@ def _async_update_ble( entry.title, push_lock ) + @callback + def _async_device_unavailable( + _service_info: bluetooth.BluetoothServiceInfoBleak, + ) -> None: + """Handle device not longer being seen by the bluetooth stack.""" + push_lock.reset_advertisement_state() + + entry.async_on_unload( + bluetooth.async_track_unavailable( + hass, _async_device_unavailable, push_lock.address + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c70669f7cc6186..b43ce18a7e9d76 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -3,7 +3,7 @@ "name": "Yale Access Bluetooth", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", - "requirements": ["yalexs-ble==1.9.2"], + "requirements": ["yalexs-ble==1.9.5"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco"], "bluetooth": [ @@ -12,8 +12,5 @@ "service_uuid": "0000fe24-0000-1000-8000-00805f9b34fb" } ], - "iot_class": "local_push", - "supported_brands": { - "august_ble": "August Bluetooth" - } + "iot_class": "local_push" } diff --git a/homeassistant/components/yalexs_ble/translations/he.json b/homeassistant/components/yalexs_ble/translations/he.json new file mode 100644 index 00000000000000..a447b36c3ec608 --- /dev/null +++ b/homeassistant/components/yalexs_ble/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_key_format": "\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05dc\u05d0 \u05de\u05e7\u05d5\u05d5\u05df \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05d7\u05e8\u05d5\u05d6\u05ea \u05d4\u05e7\u05e1\u05d3\u05e6\u05d9\u05de\u05dc\u05d9\u05ea \u05e9\u05dc 32 \u05d1\u05ea\u05d9\u05dd.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/yalexs_ble/translations/nb.json b/homeassistant/components/yalexs_ble/translations/nb.json new file mode 100644 index 00000000000000..a22f7eef3d6459 --- /dev/null +++ b/homeassistant/components/yalexs_ble/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yalexs_ble/translations/pl.json b/homeassistant/components/yalexs_ble/translations/pl.json index 2d32834337c50b..6017fb86ffbc3d 100644 --- a/homeassistant/components/yalexs_ble/translations/pl.json +++ b/homeassistant/components/yalexs_ble/translations/pl.json @@ -24,7 +24,7 @@ "key": "Klucz offline (32-bajtowy ci\u0105g szesnastkowy)", "slot": "Slot klucza offline (liczba ca\u0142kowita od 0 do 255)" }, - "description": "Sprawd\u017a dokumentacj\u0119 na {docs_url}, aby dowiedzie\u0107 si\u0119, jak znale\u017a\u0107 klucz offline." + "description": "Sprawd\u017a dokumentacj\u0119, aby dowiedzie\u0107 si\u0119, jak znale\u017a\u0107 klucz offline." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/select.pl.json b/homeassistant/components/yamaha_musiccast/translations/select.pl.json index a6e9bde7c4f606..ee42d8a74fbd25 100644 --- a/homeassistant/components/yamaha_musiccast/translations/select.pl.json +++ b/homeassistant/components/yamaha_musiccast/translations/select.pl.json @@ -1,38 +1,38 @@ { "state": { "yamaha_musiccast__dimmer": { - "auto": "Automatyczny" + "auto": "automatyczny" }, "yamaha_musiccast__zone_equalizer_mode": { - "auto": "Automatycznie", - "bypass": "Pomijanie", - "manual": "R\u0119cznie" + "auto": "automatycznie", + "bypass": "pomijanie", + "manual": "r\u0119cznie" }, "yamaha_musiccast__zone_link_audio_delay": { - "audio_sync": "Synchronizacja d\u017awi\u0119ku", - "audio_sync_off": "Synchronizacja d\u017awi\u0119ku wy\u0142\u0105czona", - "audio_sync_on": "Synchronizacja d\u017awi\u0119ku w\u0142\u0105czona", - "balanced": "Zr\u00f3wnowa\u017cone", - "lip_sync": "Synchronizacja ust" + "audio_sync": "synchronizacja d\u017awi\u0119ku", + "audio_sync_off": "synchronizacja d\u017awi\u0119ku wy\u0142\u0105czona", + "audio_sync_on": "synchronizacja d\u017awi\u0119ku w\u0142\u0105czona", + "balanced": "zr\u00f3wnowa\u017cone", + "lip_sync": "synchronizacja ust" }, "yamaha_musiccast__zone_link_audio_quality": { - "compressed": "Skompresowane", - "uncompressed": "Nieskompresowane" + "compressed": "skompresowane", + "uncompressed": "nieskompresowane" }, "yamaha_musiccast__zone_link_control": { - "speed": "Pr\u0119dko\u015b\u0107", - "stability": "Stabilno\u015b\u0107", - "standard": "Normalnie" + "speed": "pr\u0119dko\u015b\u0107", + "stability": "stabilno\u015b\u0107", + "standard": "normalnie" }, "yamaha_musiccast__zone_sleep": { "120 min": "120 minut", "30 min": "30 minut", "60 min": "60 minut", "90 min": "90 minut", - "off": "Wy\u0142\u0105czone" + "off": "wy\u0142\u0105czone" }, "yamaha_musiccast__zone_surr_decoder_type": { - "auto": "Automatycznie", + "auto": "automatycznie", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_game": "Dolby ProLogic 2x (Gra)", "dolby_pl2x_movie": "Dolby ProLogic 2x (Film)", @@ -41,12 +41,12 @@ "dts_neo6_cinema": "DTS Neo:6 (Kino)", "dts_neo6_music": "DTS Neo:6 (Muzyka)", "dts_neural_x": "DTS Neural:X", - "toggle": "Prze\u0142\u0105cz" + "toggle": "prze\u0142\u0105cz" }, "yamaha_musiccast__zone_tone_control_mode": { - "auto": "Automatyczna", - "bypass": "Pomijanie", - "manual": "R\u0119czna" + "auto": "automatyczna", + "bypass": "pomijanie", + "manual": "r\u0119czna" } } } \ No newline at end of file diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 3fdca47ef02f6b..b7a846bbc67bce 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -12,7 +12,7 @@ SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv @@ -24,7 +24,6 @@ STOP_NAME = "stop_name" USER_AGENT = "Home Assistant" -ATTRIBUTION = "Data provided by maps.yandex.ru" CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" @@ -70,6 +69,8 @@ async def async_setup_platform( class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" + _attr_attribution = "Data provided by maps.yandex.ru" + def __init__(self, requester: YandexMapsRequester, stop_id, routes, name): """Initialize sensor.""" self.requester = requester @@ -138,7 +139,7 @@ async def async_update(self, *, tries=0): attrs[route] = [] attrs[route].append(departure["text"]) attrs[STOP_NAME] = stop_name - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + if closer_time is None: self._state = None else: diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 1032ef0d2e5a82..16fe2ae7700ec0 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.2"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.32.1"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 4c0b0f69310586..7a0d409b434940 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -76,7 +76,7 @@ async def _async_connected() -> None: self._listeners.append( SsdpSearchListener( async_callback=self._async_process_entry, - service_type=SSDP_ST, + search_target=SSDP_ST, target=SSDP_TARGET, source=source, async_connect_callback=_wrap_async_connected_idx(idx), diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json index d0560ff13f5df4..10f2e4e3d94574 100644 --- a/homeassistant/components/yi/manifest.json +++ b/homeassistant/components/yi/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["ffmpeg"], "codeowners": ["@bachya"], "iot_class": "local_polling", - "loggers": ["aioftp"] + "loggers": ["aioftp"], + "integration_type": "device" } diff --git a/homeassistant/components/yolink/translations/no.json b/homeassistant/components/yolink/translations/no.json index b5e26ac910d37e..2482a294ce1629 100644 --- a/homeassistant/components/yolink/translations/no.json +++ b/homeassistant/components/yolink/translations/no.json @@ -7,7 +7,7 @@ "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", "oauth_error": "Mottatt ugyldige token data.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 19e9c635dce017..53ffb22393917a 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -39,13 +39,13 @@ async def async_setup_entry( async_add_entities( [ GasSensor(coordinator, device), - PowerMeterSensor( + EnergyMeterSensor( coordinator, device, "low", SensorStateClass.TOTAL_INCREASING ), - PowerMeterSensor( + EnergyMeterSensor( coordinator, device, "high", SensorStateClass.TOTAL_INCREASING ), - PowerMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), + EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), CurrentPowerSensor(coordinator, device), DeliveryMeterSensor(coordinator, device, "low"), DeliveryMeterSensor(coordinator, device, "high"), @@ -68,10 +68,6 @@ def __init__( ) -> None: """Create the sensor.""" super().__init__(coordinator) - self._device = device - self._device_group = device_group - self._sensor_id = sensor_id - self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{device}_{device_group}")}, @@ -149,10 +145,10 @@ def __init__( ) -> None: """Instantiate a delivery meter sensor.""" super().__init__( - coordinator, device, "delivery", "Power delivery", f"delivery_{dev_type}" + coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}" ) self._type = dev_type - self._attr_name = f"Power delivery {dev_type}" + self._attr_name = f"Energy delivery {dev_type}" @property def get_sensor(self) -> YoulessSensor | None: @@ -163,7 +159,7 @@ def get_sensor(self) -> YoulessSensor | None: return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) -class PowerMeterSensor(YoulessBaseSensor): +class EnergyMeterSensor(YoulessBaseSensor): """The Youless low meter value sensor.""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR @@ -177,13 +173,13 @@ def __init__( dev_type: str, state_class: SensorStateClass, ) -> None: - """Instantiate a power meter sensor.""" + """Instantiate a energy meter sensor.""" super().__init__( - coordinator, device, "power", "Power usage", f"power_{dev_type}" + coordinator, device, "power", "Energy usage", f"power_{dev_type}" ) self._device = device self._type = dev_type - self._attr_name = f"Power {dev_type}" + self._attr_name = f"Energy {dev_type}" self._attr_state_class = state_class @property diff --git a/homeassistant/components/zamg/__init__.py b/homeassistant/components/zamg/__init__.py index a0f80956d98ec5..67fe7521f951d1 100644 --- a/homeassistant/components/zamg/__init__.py +++ b/homeassistant/components/zamg/__init__.py @@ -1 +1,33 @@ """The zamg component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_STATION_ID, DOMAIN +from .coordinator import ZamgDataUpdateCoordinator + +PLATFORMS = (Platform.WEATHER, Platform.SENSOR) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Zamg from config entry.""" + coordinator = ZamgDataUpdateCoordinator(hass, entry=entry) + station_id = entry.data[CONF_STATION_ID] + coordinator.zamg.set_default_station(station_id) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Set up all platforms for this device/entry. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload ZAMG config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/zamg/config_flow.py b/homeassistant/components/zamg/config_flow.py new file mode 100644 index 00000000000000..43434b1e8bdcef --- /dev/null +++ b/homeassistant/components/zamg/config_flow.py @@ -0,0 +1,136 @@ +"""Config Flow for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from zamg import ZamgData + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_STATION_ID, DOMAIN, LOGGER + + +class ZamgConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for zamg integration.""" + + VERSION = 1 + + _client: ZamgData | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, Any] = {} + + if self._client is None: + self._client = ZamgData() + self._client.session = async_get_clientsession(self.hass) + + if user_input is None: + closest_station_id = await self._client.closest_station( + self.hass.config.latitude, + self.hass.config.longitude, + ) + LOGGER.debug("config_flow: closest station = %s", str(closest_station_id)) + stations = await self._client.zamg_stations() + user_input = {} + + schema = vol.Schema( + { + vol.Required( + CONF_STATION_ID, default=int(closest_station_id) + ): vol.In( + { + int(station): f"{stations[station][2]} ({station})" + for station in stations + } + ) + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + station_id = str(user_input[CONF_STATION_ID]) + + # Check if already configured + await self.async_set_unique_id(station_id) + self._abort_if_unique_id_configured() + + try: + self._client.set_default_station(station_id) + await self._client.update() + except (ValueError, TypeError) as err: + LOGGER.error("Config_flow: Received error from ZAMG: %s", err) + errors["base"] = "cannot_connect" + return self.async_abort( + reason="cannot_connect", description_placeholders=errors + ) + + return self.async_create_entry( + title=user_input.get(CONF_NAME) or self._client.get_station_name, + data={CONF_STATION_ID: station_id}, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle ZAMG configuration import.""" + station_id = str(config.get(CONF_STATION_ID)) + station_name = config.get(CONF_NAME) + # create issue every time after restart + # parameter is_persistent seems not working + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.1.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if station_id in entry.data[CONF_STATION_ID]: + return self.async_abort( + reason="already_configured", + ) + + if self._client is None: + self._client = ZamgData() + self._client.session = async_get_clientsession(self.hass) + + if station_id not in await self._client.zamg_stations(): + LOGGER.warning( + "Configured station_id %s could not be found at zamg, adding the nearest weather station instead", + station_id, + ) + latitude = config.get(CONF_LATITUDE) or self.hass.config.latitude + longitude = config.get(CONF_LONGITUDE) or self.hass.config.longitude + station_id = await self._client.closest_station(latitude, longitude) + + if not station_name: + await self._client.zamg_stations() + self._client.set_default_station(station_id) + station_name = self._client.get_station_name + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if station_id in entry.data[CONF_STATION_ID]: + return self.async_abort( + reason="already_configured", + ) + + LOGGER.debug( + "importing zamg station from configuration.yaml: station_id = %s, name = %s", + station_id, + station_name, + ) + + return await self.async_step_user( + user_input={ + CONF_STATION_ID: int(station_id), + CONF_NAME: station_name, + } + ) diff --git a/homeassistant/components/zamg/const.py b/homeassistant/components/zamg/const.py new file mode 100644 index 00000000000000..08dfd1779eabec --- /dev/null +++ b/homeassistant/components/zamg/const.py @@ -0,0 +1,26 @@ +"""Constants for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" + +from datetime import timedelta +import logging + +from homeassistant.const import Platform +from homeassistant.util import dt as dt_util + +DOMAIN = "zamg" + +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] + +LOGGER = logging.getLogger(__package__) + +ATTR_STATION = "station" +ATTR_UPDATED = "updated" +ATTRIBUTION = "Data provided by ZAMG" + +CONF_STATION_ID = "station_id" + +DEFAULT_NAME = "zamg" + +MANUFACTURER_URL = "https://www.zamg.ac.at" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py new file mode 100644 index 00000000000000..69113e8e23f1bf --- /dev/null +++ b/homeassistant/components/zamg/coordinator.py @@ -0,0 +1,46 @@ +"""Data Update coordinator for ZAMG weather data.""" +from __future__ import annotations + +from zamg import ZamgData as ZamgDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_ID, DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES + + +class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): + """Class to manage fetching ZAMG weather data.""" + + config_entry: ConfigEntry + data: dict = {} + + def __init__( + self, + hass: HomeAssistant, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global ZAMG data updater.""" + self.zamg = ZamgDevice(session=async_get_clientsession(hass)) + self.zamg.set_default_station(entry.data[CONF_STATION_ID]) + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + async def _async_update_data(self) -> ZamgDevice: + """Fetch data from ZAMG api.""" + try: + await self.zamg.zamg_stations() + device = await self.zamg.update() + except ValueError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + self.data = device + self.data["last_update"] = self.zamg.last_update + self.data["Name"] = self.zamg.get_station_name + return device diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index fc4345141896da..a6383ce8584a19 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -2,6 +2,8 @@ "domain": "zamg", "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", "documentation": "https://www.home-assistant.io/integrations/zamg", - "codeowners": [], + "requirements": ["zamg==0.1.1"], + "codeowners": ["@killer0071234"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 73902b1982f7f7..e40f42abf0b3d0 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -1,55 +1,51 @@ -"""Sensor for the Austrian "Zentralanstalt für Meteorologie und Geodynamik".""" +"""Sensor for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" from __future__ import annotations -import csv +from collections.abc import Mapping from dataclasses import dataclass -from datetime import datetime, timedelta -import gzip -import json -import logging -import os from typing import Union -import requests import voluptuous as vol from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, - LENGTH_METERS, + LENGTH_CENTIMETERS, + LENGTH_MILLIMETERS, PERCENTAGE, PRESSURE_HPA, - SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - __version__, + TIME_SECONDS, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -ATTR_STATION = "station" -ATTR_UPDATED = "updated" -ATTRIBUTION = "Data provided by ZAMG" - -CONF_STATION_ID = "station_id" - -DEFAULT_NAME = "zamg" - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_STATION, + ATTR_UPDATED, + ATTRIBUTION, + CONF_STATION_ID, + DEFAULT_NAME, + DOMAIN, + MANUFACTURER_URL, +) _DType = Union[type[int], type[float], type[str]] @@ -58,7 +54,7 @@ class ZamgRequiredKeysMixin: """Mixin for required keys.""" - col_heading: str + para_name: str dtype: _DType @@ -72,56 +68,67 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin key="pressure", name="Pressure", native_unit_of_measurement=PRESSURE_HPA, - col_heading="LDstat hPa", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="P", dtype=float, ), ZamgSensorEntityDescription( key="pressure_sealevel", name="Pressure at Sea Level", native_unit_of_measurement=PRESSURE_HPA, - col_heading="LDred hPa", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="PRED", dtype=float, ), ZamgSensorEntityDescription( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, - col_heading="RF %", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + para_name="RFAM", dtype=int, ), ZamgSensorEntityDescription( key="wind_speed", name="Wind Speed", - native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, - col_heading=f"WG {SPEED_KILOMETERS_PER_HOUR}", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + para_name="FFAM", dtype=float, ), ZamgSensorEntityDescription( key="wind_bearing", name="Wind Bearing", native_unit_of_measurement=DEGREE, - col_heading=f"WR {DEGREE}", + state_class=SensorStateClass.MEASUREMENT, + para_name="DD", dtype=int, ), ZamgSensorEntityDescription( key="wind_max_speed", name="Top Wind Speed", - native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, - col_heading=f"WSG {SPEED_KILOMETERS_PER_HOUR}", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + para_name="FFX", dtype=float, ), ZamgSensorEntityDescription( key="wind_max_bearing", name="Top Wind Bearing", native_unit_of_measurement=DEGREE, - col_heading=f"WSR {DEGREE}", + state_class=SensorStateClass.MEASUREMENT, + para_name="DDX", dtype=int, ), ZamgSensorEntityDescription( - key="sun_last_hour", - name="Sun Last Hour", - native_unit_of_measurement=PERCENTAGE, - col_heading=f"SO {PERCENTAGE}", + key="sun_last_10min", + name="Sun Last 10 Minutes", + native_unit_of_measurement=TIME_SECONDS, + state_class=SensorStateClass.MEASUREMENT, + para_name="SO", dtype=int, ), ZamgSensorEntityDescription( @@ -129,14 +136,33 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - col_heading=f"T {TEMP_CELSIUS}", + state_class=SensorStateClass.MEASUREMENT, + para_name="TL", + dtype=float, + ), + ZamgSensorEntityDescription( + key="temperature_average", + name="Temperature Average", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="TLAM", dtype=float, ), ZamgSensorEntityDescription( key="precipitation", name="Precipitation", - native_unit_of_measurement=f"l/{AREA_SQUARE_METERS}", - col_heading=f"N l/{AREA_SQUARE_METERS}", + native_unit_of_measurement=LENGTH_MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + para_name="RR", + dtype=float, + ), + ZamgSensorEntityDescription( + key="snow", + name="Snow", + native_unit_of_measurement=LENGTH_CENTIMETERS, + state_class=SensorStateClass.MEASUREMENT, + para_name="SCHNEE", dtype=float, ), ZamgSensorEntityDescription( @@ -144,42 +170,25 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin name="Dew Point", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - col_heading=f"TP {TEMP_CELSIUS}", + state_class=SensorStateClass.MEASUREMENT, + para_name="TP", dtype=float, ), - # The following probably not useful for general consumption, - # but we need them to fill in internal attributes - ZamgSensorEntityDescription( - key="station_name", - name="Station Name", - col_heading="Name", - dtype=str, - ), ZamgSensorEntityDescription( - key="station_elevation", - name="Station Elevation", - native_unit_of_measurement=LENGTH_METERS, - col_heading=f"Höhe {LENGTH_METERS}", - dtype=int, - ), - ZamgSensorEntityDescription( - key="update_date", - name="Update Date", - col_heading="Datum", - dtype=str, - ), - ZamgSensorEntityDescription( - key="update_time", - name="Update Time", - col_heading="Zeit", - dtype=str, + key="dewpoint_average", + name="Dew Point Average", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="TPAM", + dtype=float, ), ) SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] API_FIELDS: dict[str, tuple[str, _DType]] = { - desc.col_heading: (desc.key, desc.dtype) for desc in SENSOR_TYPES + desc.para_name: (desc.key, desc.dtype) for desc in SENSOR_TYPES } PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -199,187 +208,70 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the ZAMG sensor platform.""" - name = config[CONF_NAME] - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - station_id = config.get(CONF_STATION_ID) or closest_station( - latitude, longitude, hass.config.config_dir + # trigger import flow + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) - if station_id not in _get_ogd_stations(): - _LOGGER.error( - "Configured ZAMG %s (%s) is not a known station", - CONF_STATION_ID, - station_id, - ) - return - probe = ZamgData(station_id=station_id) - try: - probe.update() - except (ValueError, TypeError) as err: - _LOGGER.error("Received error from ZAMG: %s", err) - return - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - add_entities( - [ - ZamgSensor(probe, name, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ], - True, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the ZAMG sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ZamgSensor(coordinator, entry.title, entry.data[CONF_STATION_ID], description) + for description in SENSOR_TYPES ) -class ZamgSensor(SensorEntity): +class ZamgSensor(CoordinatorEntity, SensorEntity): """Implementation of a ZAMG sensor.""" _attr_attribution = ATTRIBUTION entity_description: ZamgSensorEntityDescription - def __init__(self, probe, name, description: ZamgSensorEntityDescription): + def __init__( + self, coordinator, name, station_id, description: ZamgSensorEntityDescription + ): """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.probe = probe - self._attr_name = f"{name} {description.key}" + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{station_id}_{description.key}" + self.station_id = f"{station_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=ATTRIBUTION, + configuration_url=MANUFACTURER_URL, + name=coordinator.name, + ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.probe.get_data(self.entity_description.key) + return self.coordinator.data[self.station_id].get( + self.entity_description.para_name + )["data"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, str]: """Return the state attributes.""" + update_time = self.coordinator.data.get("last_update", "") return { - ATTR_STATION: self.probe.get_data("station_name"), - ATTR_UPDATED: self.probe.last_update.isoformat(), + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_STATION: self.coordinator.data.get("Name"), + CONF_STATION_ID: self.station_id, + ATTR_UPDATED: update_time.isoformat(), } - - def update(self) -> None: - """Delegate update to probe.""" - self.probe.update() - - -class ZamgData: - """The class for handling the data retrieval.""" - - API_URL = "http://www.zamg.ac.at/ogd/" - API_HEADERS = {"User-Agent": f"home-assistant.zamg/ {__version__}"} - - def __init__(self, station_id): - """Initialize the probe.""" - self._station_id = station_id - self.data = {} - - @property - def last_update(self): - """Return the timestamp of the most recent data.""" - date, time = self.data.get("update_date"), self.data.get("update_time") - if date is not None and time is not None: - return datetime.strptime(date + time, "%d-%m-%Y%H:%M").replace( - tzinfo=VIENNA_TIME_ZONE - ) - - @classmethod - def current_observations(cls): - """Fetch the latest CSV data.""" - try: - response = requests.get(cls.API_URL, headers=cls.API_HEADERS, timeout=15) - response.raise_for_status() - response.encoding = "UTF8" - return csv.DictReader( - response.text.splitlines(), delimiter=";", quotechar='"' - ) - except requests.exceptions.HTTPError: - _LOGGER.error("While fetching data") - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from ZAMG.""" - if self.last_update and ( - self.last_update + timedelta(hours=1) - > datetime.utcnow().replace(tzinfo=dt_util.UTC) - ): - return # Not time to update yet; data is only hourly - - for row in self.current_observations(): - if row.get("Station") == self._station_id: - self.data = { - API_FIELDS[col_heading][0]: API_FIELDS[col_heading][1]( - v.replace(",", ".") - ) - for col_heading, v in row.items() - if col_heading in API_FIELDS and v - } - break - else: - raise ValueError(f"No weather data for station {self._station_id}") - - def get_data(self, variable): - """Get the data.""" - return self.data.get(variable) - - -def _get_ogd_stations(): - """Return all stations in the OGD dataset.""" - return {r["Station"] for r in ZamgData.current_observations()} - - -def _get_zamg_stations(): - """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.""" - capital_stations = _get_ogd_stations() - req = requests.get( - "https://www.zamg.ac.at/cms/en/documents/climate/" - "doc_metnetwork/zamg-observation-points", - timeout=15, - ) - stations = {} - for row in csv.DictReader(req.text.splitlines(), delimiter=";", quotechar='"'): - if row.get("synnr") in capital_stations: - try: - stations[row["synnr"]] = tuple( - float(row[coord].replace(",", ".")) - for coord in ("breite_dezi", "länge_dezi") - ) - except KeyError: - _LOGGER.error("ZAMG schema changed again, cannot autodetect station") - return stations - - -def zamg_stations(cache_dir): - """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. - - Results from internet requests are cached as compressed json, making - subsequent calls very much faster. - """ - cache_file = os.path.join(cache_dir, ".zamg-stations.json.gz") - if not os.path.isfile(cache_file): - stations = _get_zamg_stations() - with gzip.open(cache_file, "wt") as cache: - json.dump(stations, cache, sort_keys=True) - return stations - with gzip.open(cache_file, "rt") as cache: - return {k: tuple(v) for k, v in json.load(cache).items()} - - -def closest_station(lat, lon, cache_dir): - """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon.""" - if lat is None or lon is None or not os.path.isdir(cache_dir): - return - stations = zamg_stations(cache_dir) - - def comparable_dist(zamg_id): - """Calculate the pseudo-distance from lat/lon.""" - station_lat, station_lon = stations[zamg_id] - return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 - - return min(stations, key=comparable_dist) diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json new file mode 100644 index 00000000000000..74b3c7c9fa288d --- /dev/null +++ b/homeassistant/components/zamg/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up ZAMG to integrate with Home Assistant.", + "data": { + "station_id": "Station ID (Defaults to nearest station)" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The ZAMG YAML configuration is being removed", + "description": "Configuring ZAMG using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the ZAMG YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/zamg/translations/de.json b/homeassistant/components/zamg/translations/de.json new file mode 100644 index 00000000000000..084d65de978ffa --- /dev/null +++ b/homeassistant/components/zamg/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Wetterstation ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "error": { + "unknown": "ID der Wetterstation ist unbekannt", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "station_id": "ID der Wetterstation (nächstgelegene Station as Defaultwert)" + }, + "description": "Richte zamg f\u00fcr die Integration mit Home Assistant ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/translations/en.json b/homeassistant/components/zamg/translations/en.json new file mode 100644 index 00000000000000..6931f9f96f5d08 --- /dev/null +++ b/homeassistant/components/zamg/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "station_id": "Station ID (Defaults to nearest station)" + }, + "description": "Set up ZAMG to integrate with Home Assistant." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring ZAMG using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the ZAMG YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The ZAMG YAML configuration is being removed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index eb2992df64f388..b9d8ba67bbfe9b 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -1,42 +1,29 @@ -"""Sensor for data from Austrian Zentralanstalt für Meteorologie.""" +"""Sensor for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" from __future__ import annotations -import logging - import voluptuous as vol -from homeassistant.components.weather import ( - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_SPEED, - PLATFORM_SCHEMA, - WeatherEntity, -) +from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + LENGTH_MILLIMETERS, PRESSURE_HPA, - SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -# Reuse data and API logic from the sensor implementation -from .sensor import ( - ATTRIBUTION, - CONF_STATION_ID, - ZamgData, - closest_station, - zamg_stations, -) - -_LOGGER = logging.getLogger(__name__) +from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL +from .coordinator import ZamgDataUpdateCoordinator PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -52,93 +39,101 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the ZAMG weather platform.""" - name = config.get(CONF_NAME) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - station_id = config.get(CONF_STATION_ID) or closest_station( - latitude, longitude, hass.config.config_dir + # trigger import flow + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) - if station_id not in zamg_stations(hass.config.config_dir): - _LOGGER.error( - "Configured ZAMG %s (%s) is not a known station", - CONF_STATION_ID, - station_id, - ) - return - probe = ZamgData(station_id=station_id) - try: - probe.update() - except (ValueError, TypeError) as err: - _LOGGER.error("Received error from ZAMG: %s", err) - return - add_entities([ZamgWeather(probe, name)], True) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the ZAMG weather platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ZamgWeather(coordinator, entry.title, entry.data[CONF_STATION_ID])] + ) -class ZamgWeather(WeatherEntity): +class ZamgWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" - _attr_native_pressure_unit = PRESSURE_HPA - _attr_native_temperature_unit = TEMP_CELSIUS - _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR - - def __init__(self, zamg_data, stationname=None): + def __init__( + self, coordinator: ZamgDataUpdateCoordinator, name, station_id + ) -> None: """Initialise the platform with a data instance and station name.""" - self.zamg_data = zamg_data - self.stationname = stationname - - @property - def name(self): - """Return the name of the sensor.""" - return ( - self.stationname - or f"ZAMG {self.zamg_data.data.get('Name') or '(unknown station)'}" + super().__init__(coordinator) + self._attr_unique_id = f"{name}_{station_id}" + self._attr_name = f"ZAMG {name}" + self.station_id = f"{station_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=ATTRIBUTION, + configuration_url=MANUFACTURER_URL, + name=coordinator.name, ) + # set units of ZAMG API + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return None @property - def attribution(self): + def attribution(self) -> str | None: """Return the attribution.""" return ATTRIBUTION @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the platform temperature.""" - return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE) + try: + return float(self.coordinator.data[self.station_id].get("TL")["data"]) + except (TypeError, ValueError): + return None @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" - return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE) + try: + return float(self.coordinator.data[self.station_id].get("P")["data"]) + except (TypeError, ValueError): + return None @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" - return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY) + try: + return float(self.coordinator.data[self.station_id].get("RFAM")["data"]) + except (TypeError, ValueError): + return None @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED) + try: + return float(self.coordinator.data[self.station_id].get("FF")["data"]) + except (TypeError, ValueError): + return None @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self.zamg_data.get_data(ATTR_WEATHER_WIND_BEARING) - - def update(self) -> None: - """Update current conditions.""" - self.zamg_data.update() + try: + return self.coordinator.data[self.station_id].get("DD")["data"] + except (TypeError, ValueError): + return None diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 5a2fc61f897669..62783e641d392b 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -22,11 +22,7 @@ from homeassistant.components import network from homeassistant.components.network import MDNS_TARGET_IP, async_get_source_ip from homeassistant.components.network.models import Adapter -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - __version__, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow, instance_id @@ -40,6 +36,7 @@ async_get_zeroconf, bind_hass, ) +from homeassistant.setup import async_when_setup_or_start from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher @@ -194,7 +191,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: discovery = ZeroconfDiscovery(hass, zeroconf, zeroconf_types, homekit_models, ipv6) await discovery.async_setup() - async def _async_zeroconf_hass_start(_event: Event) -> None: + async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. Wait till started or otherwise HTTP is not up and running. @@ -206,7 +203,7 @@ async def _async_zeroconf_hass_stop(_event: Event) -> None: await discovery.async_stop() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_zeroconf_hass_stop) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_zeroconf_hass_start) + async_when_setup_or_start(hass, "frontend", _async_zeroconf_hass_start) return True @@ -237,12 +234,20 @@ def _get_announced_addresses( return address_list +def _filter_disallowed_characters(name: str) -> str: + """Filter disallowed characters from a string. + + . is a reversed character for zeroconf. + """ + return name.replace(".", " ") + + async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: # Get instance UUID valid_location_name = _truncate_location_name_to_valid( - hass.config.location_name or "Home" + _filter_disallowed_characters(hass.config.location_name or "Home") ) params = { diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5fcb514ea51d77..382cf42b54fde7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.39.1"], + "requirements": ["zeroconf==0.39.4"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 6cbcdf50983071..c68136c23daf90 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -3,7 +3,7 @@ import asyncio import logging -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups @@ -31,6 +31,7 @@ ATTR_LEVEL, ATTR_MANUFACTURER, ATTR_MEMBERS, + ATTR_PARAMS, ATTR_TYPE, ATTR_VALUE, ATTR_WARNING_DEVICE_DURATION, @@ -69,6 +70,7 @@ from .core.helpers import ( async_cluster_exists, async_is_bindable_target, + cluster_command_schema_to_vol_schema, convert_install_code, get_matched_clusters, qr_to_install_code, @@ -110,6 +112,17 @@ IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + SERVICE_PERMIT_PARAMS = { vol.Optional(ATTR_IEEE): IEEE_SCHEMA, vol.Optional(ATTR_DURATION, default=60): vol.All( @@ -181,17 +194,22 @@ ): cv.positive_int, } ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), ), SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( { @@ -711,6 +729,8 @@ async def websocket_device_cluster_commands( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] @@ -731,6 +751,10 @@ async def websocket_device_cluster_commands( TYPE: CLIENT, ID: cmd_id, ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), } ) for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): @@ -739,6 +763,10 @@ async def websocket_device_cluster_commands( TYPE: CLUSTER_COMMAND_SERVER, ID: cmd_id, ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), } ) _LOGGER.debug( @@ -1285,41 +1313,45 @@ async def issue_zigbee_cluster_command(service: ServiceCall) -> None: cluster_type: str = service.data[ATTR_CLUSTER_TYPE] command: int = service.data[ATTR_COMMAND] command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list = service.data[ATTR_ARGS] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) zha_device = zha_gateway.get_device(ieee) - response = None if zha_device is not None: if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: manufacturer = zha_device.manufacturer_code - response = await zha_device.issue_cluster_command( + + await zha_device.issue_cluster_command( endpoint_id, cluster_id, command, command_type, - *args, + args, + params, cluster_type=cluster_type, manufacturer=manufacturer, ) - _LOGGER.debug( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) + _LOGGER.debug( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") async_register_admin_service( hass, diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index fcc040cbde2e16..41f3846e97f783 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -109,6 +109,7 @@ def create_entity( _attr_device_class: ButtonDeviceClass = ButtonDeviceClass.UPDATE _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name = "Identify" _command_name = "identify" def get_args(self) -> list[Any]: @@ -118,7 +119,7 @@ def get_args(self) -> list[Any]: class ZHAAttributeButton(ZhaEntity, ButtonEntity): - """Defines a ZHA button, which stes value to an attribute.""" + """Defines a ZHA button, which writes a value to an attribute.""" _attribute_name: str _attribute_value: Any = None @@ -159,6 +160,7 @@ class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): """Defines a ZHA frost lock reset button.""" _attribute_name = "frost_lock_reset" + _attr_name = "Frost lock reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG @@ -171,6 +173,7 @@ class NoPresenceStatusResetButton( """Defines a ZHA no presence status reset button.""" _attribute_name = "reset_no_presence_status" + _attr_name = "Presence status reset" _attribute_value = 1 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index a4e1be78c08b5b..155a254217e2c7 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -765,6 +765,7 @@ def hvac_modes(self) -> list[HVACMode]: "_TZE200_e9ba97vf", # TV01-ZG "_TZE200_hue3yfsn", # TV02-ZG "_TZE200_husqqvux", # TSL-TRV-TV01ZG + "_TZE200_kds0pmmv", # MOES TRV TV02 "_TZE200_kly8gjlz", # TV05-ZG "_TZE200_lnbfnyxd", "_TZE200_mudxchsu", diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index d310157327b629..c028a6021da9c5 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -28,6 +28,7 @@ SIGNAL_UPDATE_DEVICE, ) from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command +from .helpers import is_hue_motion_sensor if TYPE_CHECKING: from . import ChannelPool @@ -152,6 +153,15 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize Basic channel.""" + super().__init__(cluster, ch_pool) + if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["trigger_indicator"] = True + @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) class BinaryInput(ZigbeeChannel): @@ -331,6 +341,19 @@ def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: super().__init__(cluster, ch_pool) self._off_listener = None + if self.cluster.endpoint.model in ( + "TS011F", + "TS0121", + "TS0001", + "TS0002", + "TS0003", + "TS0004", + ): + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["power_on_state"] = True + @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" diff --git a/homeassistant/components/zha/core/channels/helpers.py b/homeassistant/components/zha/core/channels/helpers.py new file mode 100644 index 00000000000000..2297af312ebe6f --- /dev/null +++ b/homeassistant/components/zha/core/channels/helpers.py @@ -0,0 +1,15 @@ +"""Helpers for use with ZHA Zigbee channels.""" +from .base import ZigbeeChannel + + +def is_hue_motion_sensor(channel: ZigbeeChannel) -> bool: + """Return true if the manufacturer and model match known Hue motion sensor models.""" + return channel.cluster.endpoint.manufacturer in ( + "Philips", + "Signify Netherlands B.V.", + ) and channel.cluster.endpoint.model in ( + "SML001", + "SML002", + "SML003", + "SML004", + ) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1754b9aff68e10..e70eea11a8742a 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -44,6 +44,7 @@ class ColorChannel(ZigbeeChannel): "color_temp_physical_max": True, "color_capabilities": True, "color_loop_active": False, + "start_up_color_temperature": True, } @cached_property diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 724a794007d558..814e7700d01e04 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -59,10 +59,45 @@ class PhillipsRemote(ZigbeeChannel): REPORT_CONFIG = () +@registries.CHANNEL_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.TUYA_MANUFACTURER_CLUSTER) +class TuyaChannel(ZigbeeChannel): + """Channel for the Tuya manufacturer Zigbee cluster.""" + + REPORT_CONFIG = () + + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize TuyaChannel.""" + super().__init__(cluster, ch_pool) + + if self.cluster.endpoint.manufacturer in ( + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + ): + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + "backlight_mode": True, + "power_on_state": True, + } + + @registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) @registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0) class OppleRemote(ZigbeeChannel): - """Opple button channel.""" + """Opple channel.""" REPORT_CONFIG = () @@ -82,6 +117,10 @@ def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: "motion_sensitivity": True, "approach_distance": True, } + elif self.cluster.endpoint.model in ("lumi.plug.mmeu01", "lumi.plug.maeu01"): + self.ZCL_INIT_ATTRS = { + "power_outage_memory": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" @@ -158,7 +197,7 @@ class LEDEffectType(types.enum8): Clear = 0xFF REPORT_CONFIG = () - ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + ZCL_INIT_ATTRS = { "dimming_speed_up_remote": False, "dimming_speed_up_local": False, "ramp_rate_off_to_on_local": False, diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index fa6f9c07dee88b..be61a75962ee23 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -1,4 +1,9 @@ """Measurement channels module for Zigbee Home Automation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import zigpy.zcl from zigpy.zcl.clusters import measurement from .. import registries @@ -9,6 +14,10 @@ REPORT_CONFIG_MIN_INT, ) from .base import AttrReportConfig, ZigbeeChannel +from .helpers import is_hue_motion_sensor + +if TYPE_CHECKING: + from . import ChannelPool @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) @@ -50,6 +59,15 @@ class OccupancySensing(ZigbeeChannel): AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), ) + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize Occupancy channel.""" + super().__init__(cluster, ch_pool) + if is_hue_motion_sensor(self): + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["sensitivity"] = True + @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) class PressureMeasurement(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index a0a4521e19d213..5eb436cbe53106 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,11 +17,14 @@ from zigpy.profiles import PROFILES import zigpy.quirks from zigpy.types.named import EUI64, NWK +from zigpy.zcl.clusters import Cluster from zigpy.zcl.clusters.general import Groups, Identify +from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types from homeassistant.const import ATTR_COMMAND, ATTR_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -35,6 +38,7 @@ ATTR_ATTRIBUTE, ATTR_AVAILABLE, ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, ATTR_COMMAND_TYPE, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_ID, @@ -49,6 +53,7 @@ ATTR_NEIGHBORS, ATTR_NODE_DESCRIPTOR, ATTR_NWK, + ATTR_PARAMS, ATTR_POWER_SOURCE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, @@ -74,7 +79,7 @@ UNKNOWN_MODEL, ZHA_OPTIONS, ) -from .helpers import LogMixin, async_get_zha_config_value +from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: from ..api import ClusterBinding @@ -558,7 +563,7 @@ def zha_device_info(self) -> dict[str, Any]: return device_info @callback - def async_get_clusters(self): + def async_get_clusters(self) -> dict[int, dict[str, dict[int, Cluster]]]: """Get all clusters for this device.""" return { ep_id: { @@ -592,9 +597,11 @@ def async_get_std_clusters(self): } @callback - def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN): + def async_get_cluster( + self, endpoint_id: int, cluster_id: int, cluster_type: str = CLUSTER_TYPE_IN + ) -> Cluster: """Get zigbee cluster from this entity.""" - clusters = self.async_get_clusters() + clusters: dict[int, dict[str, dict[int, Cluster]]] = self.async_get_clusters() return clusters[endpoint_id][cluster_type][cluster_id] @callback @@ -660,36 +667,62 @@ async def write_zigbee_attribute( async def issue_cluster_command( self, - endpoint_id, - cluster_id, - command, - command_type, - *args, - cluster_type=CLUSTER_TYPE_IN, - manufacturer=None, - ): - """Issue a command against specified zigbee cluster on this entity.""" - cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) - if cluster is None: - return None - if command_type == CLUSTER_COMMAND_SERVER: - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True + endpoint_id: int, + cluster_id: int, + command: int, + command_type: str, + args: list | None, + params: dict[str, Any] | None, + cluster_type: str = CLUSTER_TYPE_IN, + manufacturer: int | None = None, + ) -> None: + """Issue a command against specified zigbee cluster on this device.""" + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while issuing command {command} with args {args}" + ) from exc + commands: dict[int, ZCLCommandDef] = ( + cluster.server_commands + if command_type == CLUSTER_COMMAND_SERVER + else cluster.client_commands + ) + if args is not None: + self.warning( + "args [%s] are deprecated and should be passed with the params key. The parameter names are: %s", + args, + [field.name for field in commands[command].schema.fields], ) + response = await getattr(cluster, commands[command].name)(*args) else: - response = await cluster.client_command(command, *args) - + assert params is not None + response = await ( + getattr(cluster, commands[command].name)( + **convert_to_zcl_values(params, commands[command].schema) + ) + ) self.debug( - "Issued cluster command: %s %s %s %s %s %s %s", - f"{ATTR_CLUSTER_ID}: {cluster_id}", - f"{ATTR_COMMAND}: {command}", - f"{ATTR_COMMAND_TYPE}: {command_type}", - f"{ATTR_ARGS}: {args}", - f"{ATTR_CLUSTER_ID}: {cluster_type}", - f"{ATTR_MANUFACTURER}: {manufacturer}", - f"{ATTR_ENDPOINT_ID}: {endpoint_id}", + "Issued cluster command: %s %s %s %s %s %s %s %s", + f"{ATTR_CLUSTER_ID}: [{cluster_id}]", + f"{ATTR_CLUSTER_TYPE}: [{cluster_type}]", + f"{ATTR_ENDPOINT_ID}: [{endpoint_id}]", + f"{ATTR_COMMAND}: [{command}]", + f"{ATTR_COMMAND_TYPE}: [{command_type}]", + f"{ATTR_ARGS}: [{args}]", + f"{ATTR_PARAMS}: [{params}]", + f"{ATTR_MANUFACTURER}: [{manufacturer}]", ) - return response + if response is None: + return # client commands don't return a response + if isinstance(response, Exception): + raise HomeAssistantError("Failed to issue cluster command") from response + if response[1] is not ZclStatus.SUCCESS: + raise HomeAssistantError( + f"Failed to issue cluster command with status: {response[1]}" + ) async def async_add_to_group(self, group_id: int) -> None: """Add this device to the provided zigbee group.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5261396c79461a..17adc5fc8487a1 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -754,7 +754,7 @@ def emit(self, record: LogRecord) -> None: "|".join([re.escape(x) for x in (hass_path, config_dir)]) ) ) - entry = LogEntry(record, stack, _figure_out_source(record, stack, paths_re)) + entry = LogEntry(record, _figure_out_source(record, stack, paths_re)) async_dispatcher_send( self.hass, ZHA_GW_MSG, diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 7fd789ac3f51bd..1ea9a2a4c9be2b 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -10,6 +10,7 @@ import binascii from collections.abc import Callable, Iterator from dataclasses import dataclass +import enum import functools import itertools import logging @@ -22,12 +23,13 @@ import zigpy.types import zigpy.util import zigpy.zcl +from zigpy.zcl.foundation import CommandSchema import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import IntegrationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CLUSTER_TYPE_IN, @@ -120,6 +122,74 @@ async def get_matched_clusters( return clusters_to_bind +def cluster_command_schema_to_vol_schema(schema: CommandSchema) -> vol.Schema: + """Convert a cluster command schema to a voluptuous schema.""" + return vol.Schema( + { + vol.Optional(field.name) + if field.optional + else vol.Required(field.name): schema_type_to_vol(field.type) + for field in schema.fields + } + ) + + +def schema_type_to_vol(field_type: Any) -> Any: + """Convert a schema type to a voluptuous type.""" + if issubclass(field_type, enum.Flag) and len(field_type.__members__.keys()): + return cv.multi_select( + [key.replace("_", " ") for key in field_type.__members__.keys()] + ) + if issubclass(field_type, enum.Enum) and len(field_type.__members__.keys()): + return vol.In([key.replace("_", " ") for key in field_type.__members__.keys()]) + if ( + issubclass(field_type, zigpy.types.FixedIntType) + or issubclass(field_type, enum.Flag) + or issubclass(field_type, enum.Enum) + ): + return vol.All( + vol.Coerce(int), vol.Range(field_type.min_value, field_type.max_value) + ) + return str + + +def convert_to_zcl_values( + fields: dict[str, Any], schema: CommandSchema +) -> dict[str, Any]: + """Convert user input to ZCL values.""" + converted_fields: dict[str, Any] = {} + for field in schema.fields: + if field.name not in fields: + continue + value = fields[field.name] + if issubclass(field.type, enum.Flag) and isinstance(value, list): + new_value = 0 + + for flag in value: + if isinstance(flag, str): + new_value |= field.type[flag.replace(" ", "_")] + else: + new_value |= flag + + value = field.type(new_value) + elif issubclass(field.type, enum.Enum): + value = ( + field.type[value.replace(" ", "_")] + if isinstance(value, str) + else field.type(value) + ) + else: + value = field.type(value) + _LOGGER.debug( + "Converted ZCL schema field(%s) value from: %s to: %s", + field.name, + fields[field.name], + value, + ) + converted_fields[field.name] = value + return converted_fields + + @callback def async_is_bindable_target(source_zha_device, target_zha_device): """Determine if target is bindable to source.""" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 2480cf1cd43a20..42f6bb55f5199a 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -33,6 +33,7 @@ SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45 +TUYA_MANUFACTURER_CLUSTER = 0xEF00 VOC_LEVEL_CLUSTER = 0x042E REMOTE_DEVICE_TYPES = { diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 1cb988b1c1513f..3e2a3591c804cf 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -93,7 +93,7 @@ ), INOVELLI_INDIVIDUAL_LED_EFFECT: vol.Schema( { - vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(1, 7)), + vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(0, 6)), vol.Required("effect_type"): vol.In( InovelliConfigEntityChannel.LEDEffectType.__members__.keys() ), diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5796fc99f3fa1f..e40a54c11bce99 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,11 +7,11 @@ "bellows==0.34.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.83", + "zha-quirks==0.0.84", "zigpy-deconz==0.19.0", - "zigpy==0.51.3", + "zigpy==0.51.5", "zigpy-xbee==0.16.2", - "zigpy-zigate==0.10.2", + "zigpy-zigate==0.10.3", "zigpy-znp==0.9.1" ], "usb": [ diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 3bace412744f36..1776cabf125682 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -19,6 +19,7 @@ from .core import discovery from .core.const import ( CHANNEL_ANALOG_OUTPUT, + CHANNEL_COLOR, CHANNEL_INOVELLI, CHANNEL_LEVEL, DATA_ZHA, @@ -455,6 +456,7 @@ class AqaraMotionDetectionInterval( _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" + _attr_name = "Detection interval" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -466,6 +468,7 @@ class OnOffTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" + _attr_name = "On/Off transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -475,6 +478,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" + _attr_name = "On level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -486,6 +490,7 @@ class OnTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" + _attr_name = "On transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -497,6 +502,7 @@ class OffTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" + _attr_name = "Off transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -508,6 +514,7 @@ class DefaultMoveRateConfigurationEntity( _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" + _attr_name = "Default move rate" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -519,6 +526,32 @@ class StartUpCurrentLevelConfigurationEntity( _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" + _attr_name = "Start-up current level" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_COLOR) +class StartUpColorTemperatureConfigurationEntity( + ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" +): + """Representation of a ZHA startup color temperature configuration entity.""" + + _attr_native_min_value: float = 153 + _attr_native_max_value: float = 500 + _zcl_attribute: str = "start_up_color_temperature" + _attr_name = "Start-up color temperature" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ZigbeeChannel], + **kwargs: Any, + ) -> None: + """Init this ZHA startup color temperature entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + if self._channel: + self._attr_native_min_value: float = self._channel.min_mireds + self._attr_native_max_value: float = self._channel.max_mireds @CONFIG_DIAGNOSTIC_MATCH( @@ -536,6 +569,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_native_max_value: float = 0x257 _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" + _attr_name = "Timer duration" @CONFIG_DIAGNOSTIC_MATCH(channel_names="ikea_airpurifier") @@ -548,6 +582,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "filter_life_time" + _attr_name = "Filter life time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 8b2623b4de1e80..5ac0ec6d16408f 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -22,6 +22,7 @@ from .core.const import ( CHANNEL_IAS_WD, CHANNEL_INOVELLI, + CHANNEL_OCCUPANCY, CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -123,6 +124,7 @@ class ZHADefaultToneSelectEntity( """Representation of a ZHA default siren tone select entity.""" _enum = IasWd.Warning.WarningMode + _attr_name = "Default siren tone" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -132,6 +134,7 @@ class ZHADefaultSirenLevelSelectEntity( """Representation of a ZHA default siren level select entity.""" _enum = IasWd.Warning.SirenLevel + _attr_name = "Default siren level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -141,6 +144,7 @@ class ZHADefaultStrobeLevelSelectEntity( """Representation of a ZHA default siren strobe level select entity.""" _enum = IasWd.StrobeLevel + _attr_name = "Default strobe level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -148,6 +152,7 @@ class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__nam """Representation of a ZHA default siren strobe select entity.""" _enum = Strobe + _attr_name = "Default strobe" class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @@ -220,6 +225,86 @@ class ZHAStartupOnOffSelectEntity( _select_attr = "start_up_on_off" _enum = OnOff.StartUpOnOff + _attr_name = "Start-up behavior" + + +class TuyaPowerOnState(types.enum8): + """Tuya power on state enum.""" + + Off = 0x00 + On = 0x01 + LastState = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_ON_OFF, + models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, +) +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="tuya_manufacturer", + manufacturers={ + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + }, +) +class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity, id_suffix="power_on_state"): + """Representation of a ZHA power on state select entity.""" + + _select_attr = "power_on_state" + _enum = TuyaPowerOnState + _attr_name = "Power on state" + + +class MoesBacklightMode(types.enum8): + """MOES switch backlight mode enum.""" + + Off = 0x00 + LightWhenOn = 0x01 + LightWhenOff = 0x02 + Freeze = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="tuya_manufacturer", + manufacturers={ + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + }, +) +class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): + """Moes devices have a different backlight mode select options.""" + + _select_attr = "backlight_mode" + _enum = MoesBacklightMode + _attr_name = "Backlight mode" class AqaraMotionSensitivities(types.enum8): @@ -234,10 +319,55 @@ class AqaraMotionSensitivities(types.enum8): channel_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02"} ) class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): - """Representation of a ZHA on off transition time configuration entity.""" + """Representation of a ZHA motion sensitivity configuration entity.""" _select_attr = "motion_sensitivity" _enum = AqaraMotionSensitivities + _attr_name = "Motion sensitivity" + + +class HueV1MotionSensitivities(types.enum8): + """Hue v1 motion sensitivities.""" + + Low = 0x00 + Medium = 0x01 + High = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001"}, +) +class HueV1MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _select_attr = "sensitivity" + _attr_name = "Hue motion sensitivity" + _enum = HueV1MotionSensitivities + + +class HueV2MotionSensitivities(types.enum8): + """Hue v2 motion sensitivities.""" + + Lowest = 0x00 + Low = 0x01 + Medium = 0x02 + High = 0x03 + Highest = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML002", "SML003", "SML004"}, +) +class HueV2MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _select_attr = "sensitivity" + _attr_name = "Hue motion sensitivity" + _enum = HueV2MotionSensitivities class AqaraMonitoringModess(types.enum8): @@ -253,6 +383,7 @@ class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): _select_attr = "monitoring_mode" _enum = AqaraMonitoringModess + _attr_name = "Monitoring mode" class AqaraApproachDistances(types.enum8): @@ -269,6 +400,7 @@ class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): _select_attr = "approach_distance" _enum = AqaraApproachDistances + _attr_name = "Approach distance" class AqaraE1ReverseDirection(types.enum8): @@ -286,6 +418,7 @@ class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): _select_attr = "window_covering_mode" _enum = AqaraE1ReverseDirection + _attr_name = "Curtain mode" class InovelliOutputMode(types.enum1): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 74ec924af78e86..ba4aec66f35a10 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -216,8 +216,9 @@ class Battery(Sensor): SENSOR_ATTR = "battery_percentage_remaining" _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _unit = PERCENTAGE _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name: str = "Battery" + _unit = PERCENTAGE @classmethod def create_entity( @@ -268,6 +269,7 @@ class ElectricalMeasurement(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_should_poll = True # BaseZhaEntity defaults to False _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Active power" _unit = POWER_WATT _div_mul_prefix = "ac_power" @@ -309,6 +311,7 @@ class ElectricalMeasurementApparentPower( SENSOR_ATTR = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "Apparent power" _unit = POWER_VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -320,6 +323,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr SENSOR_ATTR = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "RMS current" _unit = ELECTRIC_CURRENT_AMPERE _div_mul_prefix = "ac_current" @@ -331,6 +335,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt SENSOR_ATTR = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "RMS voltage" _unit = ELECTRIC_POTENTIAL_VOLT _div_mul_prefix = "ac_voltage" @@ -342,6 +347,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque SENSOR_ATTR = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "AC frequency" _unit = FREQUENCY_HERTZ _div_mul_prefix = "ac_frequency" @@ -353,6 +359,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f SENSOR_ATTR = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "Power factor" _unit = PERCENTAGE @@ -366,6 +373,7 @@ class Humidity(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Humidity" _divisor = 100 _unit = PERCENTAGE @@ -377,6 +385,7 @@ class SoilMoisture(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Soil moisture" _divisor = 100 _unit = PERCENTAGE @@ -388,6 +397,7 @@ class LeafWetness(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Leaf wetness" _divisor = 100 _unit = PERCENTAGE @@ -399,6 +409,7 @@ class Illuminance(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Illuminance" _unit = LIGHT_LUX def formatter(self, value: int) -> float: @@ -416,6 +427,7 @@ class SmartEnergyMetering(Sensor): SENSOR_ATTR: int | str = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Instantaneous demand" unit_of_measure_map = { 0x00: POWER_WATT, @@ -463,6 +475,7 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") SENSOR_ATTR: int | str = "current_summ_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING + _attr_name: str = "Summation delivered" unit_of_measure_map = { 0x00: ENERGY_KILO_WATT_HOUR, @@ -513,6 +526,7 @@ class Pressure(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Pressure" _decimals = 0 _unit = PRESSURE_HPA @@ -524,6 +538,7 @@ class Temperature(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Temperature" _divisor = 100 _unit = TEMP_CELSIUS @@ -535,6 +550,7 @@ class DeviceTemperature(Sensor): SENSOR_ATTR = "current_temperature" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Device temperature" _divisor = 100 _unit = TEMP_CELSIUS _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -547,6 +563,7 @@ class CarbonDioxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Carbon dioxide concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -559,6 +576,7 @@ class CarbonMonoxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Carbon monoxide concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -572,6 +590,7 @@ class VOCLevel(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -588,6 +607,7 @@ class PPBVOCLevel(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1 _unit = CONCENTRATION_PARTS_PER_BILLION @@ -599,6 +619,7 @@ class PM25(Sensor): SENSOR_ATTR = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Particulate matter" _decimals = 0 _multiplier = 1 _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -610,6 +631,7 @@ class FormaldehydeConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Formaldehyde concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -619,6 +641,8 @@ class FormaldehydeConcentration(Sensor): class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" + _attr_name: str = "HVAC action" + @classmethod def create_entity( cls: type[_ThermostatHVACActionSelfT], @@ -744,6 +768,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_name: str = "RSSI" unique_id_suffix: str @classmethod @@ -773,6 +798,8 @@ def native_value(self) -> StateType: class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" + _attr_name: str = "LQI" + @MULTI_MATCH( channel_names="tuya_manufacturer", @@ -786,6 +813,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): SENSOR_ATTR = "timer_time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Time left" _unit = TIME_MINUTES @@ -796,6 +824,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): SENSOR_ATTR = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Device run time" _unit = TIME_MINUTES @@ -806,4 +835,5 @@ class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): SENSOR_ATTR = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Filter run time" _unit = TIME_MINUTES diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index 0e645da365e764..132dae6e745a82 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -187,6 +187,11 @@ issue_zigbee_cluster_command: example: "[arg1, arg2, argN]" selector: object: + params: + name: Params + description: parameters to pass to the command + selector: + object: manufacturer: name: Manufacturer description: manufacturer code diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 47568648f2ba42..0bd55cdbe68407 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -19,6 +19,7 @@ from .core import discovery from .core.const import ( + CHANNEL_BASIC, CHANNEL_INOVELLI, CHANNEL_ON_OFF, DATA_ZHA, @@ -290,6 +291,33 @@ class P1MotionTriggerIndicatorSwitch( """Representation of a ZHA motion triggering configuration entity.""" _zcl_attribute: str = "trigger_indicator" + _attr_name = "LED trigger indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} +) +class XiaomiPlugPowerOutageMemorySwitch( + ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" +): + """Representation of a ZHA power outage memory configuration entity.""" + + _zcl_attribute: str = "power_outage_memory" + _attr_name = "Power outage memory" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_BASIC, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001", "SML002", "SML003", "SML004"}, +) +class HueMotionTriggerIndicatorSwitch( + ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" +): + """Representation of a ZHA motion triggering configuration entity.""" + + _zcl_attribute: str = "trigger_indicator" + _attr_name = "LED trigger indicator" @CONFIG_DIAGNOSTIC_MATCH( @@ -300,6 +328,7 @@ class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): """ZHA BinarySensor.""" _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" @CONFIG_DIAGNOSTIC_MATCH( @@ -310,6 +339,7 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): """ZHA BinarySensor.""" _zcl_attribute: str = "disable_led" + _attr_name = "Disable LED" @CONFIG_DIAGNOSTIC_MATCH( diff --git a/homeassistant/components/zha/translations/bg.json b/homeassistant/components/zha/translations/bg.json index 3bd7629cd957c4..1c4c44c9dffae4 100644 --- a/homeassistant/components/zha/translations/bg.json +++ b/homeassistant/components/zha/translations/bg.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 ZHA \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" }, + "flow_title": "{name}", "step": { "choose_formation_strategy": { "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0440\u0435\u0436\u043e\u0432\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0437\u0430 \u0432\u0430\u0448\u0435\u0442\u043e \u0440\u0430\u0434\u0438\u043e.", @@ -61,6 +62,11 @@ } } }, + "config_panel": { + "zha_options": { + "title": "\u0413\u043b\u043e\u0431\u0430\u043b\u043d\u0438 \u043e\u043f\u0446\u0438\u0438" + } + }, "device_automation": { "action_type": { "squawk": "\u041a\u0432\u0430\u043a", @@ -110,7 +116,8 @@ }, "options": { "abort": { - "not_zha_device": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 zha \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + "not_zha_device": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 zha \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" @@ -132,6 +139,12 @@ "description": "ZHA \u0449\u0435 \u0431\u044a\u0434\u0435 \u0441\u043f\u0440\u044f\u043d. \u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435?", "title": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 ZHA" }, + "instruct_unplug": { + "title": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u0442\u0435 \u0441\u0442\u0430\u0440\u043e\u0442\u043e \u0441\u0438 \u0440\u0430\u0434\u0438\u043e" + }, + "intent_migrate": { + "title": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e" @@ -146,6 +159,14 @@ "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0442\u0435 \u043d\u0430 \u0441\u0435\u0440\u0438\u0439\u043d\u0438\u044f \u043f\u043e\u0440\u0442", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 \u0441\u0435\u0440\u0438\u0439\u043d\u0438\u044f \u043f\u043e\u0440\u0442" }, + "prompt_migrate_or_reconfigure": { + "description": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u0442\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e \u0438\u043b\u0438 \u043f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0442\u0435\u043a\u0443\u0449\u043e\u0442\u043e \u0440\u0430\u0434\u0438\u043e?", + "menu_options": { + "intent_migrate": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e", + "intent_reconfigure": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u043e\u0442\u043e \u0440\u0430\u0434\u0438\u043e" + }, + "title": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u0438\u043b\u0438 \u043f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u041a\u0430\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 \u0444\u0430\u0439\u043b" diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 073e72b80a353d..db5d77047f5e81 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Envia efecte a tots els LEDs", + "issue_individual_led_effect": "Envia efecte a un LED individual", "squawk": "Squawk", "warn": "Av\u00eds" }, @@ -210,6 +212,14 @@ "description": "ZHA s'aturar\u00e0. Vols continuar?", "title": "Reconfiguraci\u00f3 de ZHA" }, + "instruct_unplug": { + "description": "La r\u00e0dio antiga s'ha reiniciat. Si el maquinari ja no \u00e9s necessari, ara pots desconnectar-lo.", + "title": "Desconnecta la r\u00e0dio antiga" + }, + "intent_migrate": { + "description": "La r\u00e0dio antiga es restablir\u00e0 de f\u00e0brica. Si utilitzes un adaptador de Z-Wave i Zigbee combinat com el HUSBZB-1, nom\u00e9s restablir\u00e0 la part del Zigbee. \n\nVols continuar?", + "title": "Migra a una nova r\u00e0dio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipus de r\u00e0dio" @@ -233,6 +243,14 @@ "description": "La teva c\u00f2pia de seguretat t\u00e9 una adre\u00e7a IEEE diferent de la teva r\u00e0dio. Perqu\u00e8 la xarxa funcioni correctament, tamb\u00e9 s'ha de canviar l'adre\u00e7a IEEE de la teva r\u00e0dio. \n\nAquesta \u00e9s una operaci\u00f3 permanent.", "title": "Sobreescriu l'adre\u00e7a IEEE r\u00e0dio" }, + "prompt_migrate_or_reconfigure": { + "description": "Est\u00e0s migrant a una r\u00e0dio nova o tornant a configurar la r\u00e0dio actual?", + "menu_options": { + "intent_migrate": "Migra a una nova r\u00e0dio", + "intent_reconfigure": "Torna a configurar la r\u00e0dio actual" + }, + "title": "Migraci\u00f3 o reconfiguraci\u00f3" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Puja un fitxer" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 60ea4fcc615dc8..5be0add1ae2320 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -61,7 +61,7 @@ "data": { "overwrite_coordinator_ieee": "Dauerhaftes Ersetzen der IEEE-Funkadresse" }, - "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", + "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", "title": "Funk-IEEE-Adresse \u00fcberschreiben" }, "pick_radio": { @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Ausgabeeffekt f\u00fcr alle LEDs", + "issue_individual_led_effect": "Ausgabeeffekt f\u00fcr einzelne LED", "squawk": "Kreischen", "warn": "Warnen" }, @@ -210,6 +212,14 @@ "description": "ZHA wird gestoppt. M\u00f6chtest du fortfahren?", "title": "ZHA rekonfigurieren" }, + "instruct_unplug": { + "description": "Dein altes Funkger\u00e4t wurde zur\u00fcckgesetzt. Wenn die Hardware nicht mehr ben\u00f6tigt wird, kannst du es jetzt ausstecken.", + "title": "Stecke dein altes Funkger\u00e4t aus" + }, + "intent_migrate": { + "description": "Dein altes Funkger\u00e4t wird auf die Werkseinstellungen zur\u00fcckgesetzt. Wenn du einen kombinierten Z-Wave- und Zigbee-Adapter wie den HUSBZB-1 verwendest, wird nur der Zigbee-Teil zur\u00fcckgesetzt.\n\nM\u00f6chtest du fortfahren?", + "title": "Umstellung auf ein neues Funkger\u00e4t" + }, "manual_pick_radio_type": { "data": { "radio_type": "Funktyp" @@ -230,9 +240,17 @@ "data": { "overwrite_coordinator_ieee": "Dauerhaftes Ersetzen der IEEE-Funkadresse" }, - "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", + "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", "title": "Funk-IEEE-Adresse \u00fcberschreiben" }, + "prompt_migrate_or_reconfigure": { + "description": "Stellst du auf ein neues Funkger\u00e4t um oder konfigurierst du das aktuelle Funkger\u00e4t neu?", + "menu_options": { + "intent_migrate": "Umstellung auf ein neues Funkger\u00e4t", + "intent_reconfigure": "Das aktuelle Funkger\u00e4t neu konfigurieren" + }, + "title": "Migrieren oder neu konfigurieren" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Datei hochladen" diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json index 69c437a2e0678a..2d3ca09560c77d 100644 --- a/homeassistant/components/zha/translations/el.json +++ b/homeassistant/components/zha/translations/el.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "\u0395\u03c6\u03ad \u03c0\u03c1\u03bf\u03b2\u03bb\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b3\u03b9\u03b1 \u03cc\u03bb\u03b1 \u03c4\u03b1 LED", + "issue_individual_led_effect": "\u0395\u03c6\u03ad \u03c0\u03c1\u03bf\u03b2\u03bb\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b3\u03b9\u03b1 \u03bc\u03b5\u03bc\u03bf\u03bd\u03c9\u03bc\u03ad\u03bd\u03b1 LED", "squawk": "\u039a\u03b1\u03ba\u03ac\u03c1\u03b9\u03c3\u03bc\u03b1", "warn": "\u03a0\u03c1\u03bf\u03b5\u03b9\u03b4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7" }, @@ -210,6 +212,14 @@ "description": "\u03a4\u03bf ZHA \u03b8\u03b1 \u03c3\u03c4\u03b1\u03bc\u03b1\u03c4\u03ae\u03c3\u03b5\u03b9. \u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03b5\u03c4\u03b5;", "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 ZHA" }, + "instruct_unplug": { + "description": "\u0388\u03b3\u03b9\u03bd\u03b5 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03bf\u03c1\u03ac \u03c4\u03bf\u03c5 \u03c0\u03b1\u03bb\u03b9\u03bf\u03cd \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7. \u0395\u03ac\u03bd \u03c4\u03bf \u03c5\u03bb\u03b9\u03ba\u03cc \u03b4\u03b5\u03bd \u03c7\u03c1\u03b5\u03b9\u03ac\u03b6\u03b5\u03c4\u03b1\u03b9 \u03c0\u03bb\u03ad\u03bf\u03bd, \u03bc\u03c0\u03bf\u03c1\u03b5\u03af\u03c4\u03b5 \u03c4\u03ce\u03c1\u03b1 \u03bd\u03b1 \u03c4\u03bf \u03b1\u03c0\u03bf\u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5.", + "title": "\u0391\u03c0\u03bf\u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b1\u03bb\u03b9\u03cc \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, + "intent_migrate": { + "description": "\u039f \u03c0\u03b1\u03bb\u03b9\u03cc\u03c2 \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7 \u03b8\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03b5\u03c1\u03b8\u03b5\u03af \u03b1\u03c0\u03cc \u03c4\u03b9\u03c2 \u03b5\u03c1\u03b3\u03bf\u03c3\u03c4\u03b1\u03c3\u03b9\u03b1\u03ba\u03ad\u03c2 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2. \u0395\u03ac\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03c5\u03b1\u03c3\u03bc\u03ad\u03bd\u03bf \u03c0\u03c1\u03bf\u03c3\u03b1\u03c1\u03bc\u03bf\u03b3\u03ad\u03b1 Z-Wave \u03ba\u03b1\u03b9 Zigbee \u03cc\u03c0\u03c9\u03c2 \u03c4\u03bf HUSBZB-1, \u03b1\u03c5\u03c4\u03cc \u03b8\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03b5\u03b9 \u03bc\u03cc\u03bd\u03bf \u03c4\u03bf \u03c4\u03bc\u03ae\u03bc\u03b1 Zigbee.\n\n\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03b5\u03c4\u03b5;", + "title": "\u039c\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1\u03c2" @@ -233,6 +243,14 @@ "description": "\u03a4\u03bf \u03b5\u03c6\u03b5\u03b4\u03c1\u03b9\u03ba\u03cc \u03c3\u03b1\u03c2 \u03b1\u03bd\u03c4\u03af\u03b3\u03c1\u03b1\u03c6\u03bf \u03ad\u03c7\u03b5\u03b9 \u03b4\u03b9\u03b1\u03c6\u03bf\u03c1\u03b5\u03c4\u03b9\u03ba\u03ae \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b1\u03c3\u03cd\u03c1\u03bc\u03b1\u03c4\u03cc \u03c3\u03b1\u03c2. \u0393\u03b9\u03b1 \u03bd\u03b1 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03b5\u03b9 \u03c3\u03c9\u03c3\u03c4\u03ac \u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03cc \u03c3\u03b1\u03c2, \u03b8\u03b1 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b1\u03bb\u03bb\u03ac\u03be\u03b5\u03b9 \u03ba\u03b1\u03b9 \u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE \u03c4\u03bf\u03c5 \u03c1\u03b1\u03b4\u03b9\u03bf\u03c6\u03ce\u03bd\u03bf\u03c5 \u03c3\u03b1\u03c2.\n\n\u0391\u03c5\u03c4\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03bc\u03b9\u03b1 \u03bc\u03cc\u03bd\u03b9\u03bc\u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1.", "title": "\u0391\u03bd\u03c4\u03b9\u03ba\u03b1\u03c4\u03b1\u03c3\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE Radio" }, + "prompt_migrate_or_reconfigure": { + "description": "\u03a0\u03c1\u03b1\u03b3\u03bc\u03b1\u03c4\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b5 \u03bc\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7 \u03ae \u03c1\u03c5\u03b8\u03bc\u03af\u03b6\u03b5\u03c4\u03b5 \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03bc\u03ad\u03c4\u03c1\u03bf\u03c5\u03c2 \u03c4\u03bf\u03c5 \u03c4\u03c1\u03ad\u03c7\u03bf\u03bd\u03c4\u03bf\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7;", + "menu_options": { + "intent_migrate": "\u039c\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7", + "intent_reconfigure": "\u0395\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c4\u03c1\u03ad\u03c7\u03bf\u03bd\u03c4\u03bf\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, + "title": "\u039c\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03ae \u03b5\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u0391\u03bd\u03b5\u03b2\u03ac\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 68d36b7fac7db7..f624f4d54991df 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -64,12 +64,35 @@ "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "title": "Overwrite Radio IEEE Address" }, + "pick_radio": { + "data": { + "radio_type": "Radio Type" + }, + "description": "Pick a type of your Zigbee radio", + "title": "Radio Type" + }, + "port_config": { + "data": { + "baudrate": "port speed", + "flow_control": "data flow control", + "path": "Serial device path" + }, + "description": "Enter port specific settings", + "title": "Settings" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Upload a file" }, "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "title": "Upload a Manual Backup" + }, + "user": { + "data": { + "path": "Serial Device Path" + }, + "description": "Select serial port for Zigbee radio", + "title": "ZHA" } } }, @@ -93,10 +116,10 @@ }, "device_automation": { "action_type": { - "squawk": "Squawk", - "warn": "Warn", "issue_all_led_effect": "Issue effect for all LEDs", - "issue_individual_led_effect": "Issue effect for individual LED" + "issue_individual_led_effect": "Issue effect for individual LED", + "squawk": "Squawk", + "warn": "Warn" }, "trigger_subtype": { "both_buttons": "Both buttons", diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index 7aa42172d0502f..2919302a1ac5a2 100644 --- a/homeassistant/components/zha/translations/es.json +++ b/homeassistant/components/zha/translations/es.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efecto de emisi\u00f3n para todos los LEDs", + "issue_individual_led_effect": "Efecto de emisi\u00f3n para LED individual", "squawk": "Squawk", "warn": "Advertir" }, @@ -210,6 +212,14 @@ "description": "ZHA se detendr\u00e1. \u00bfDeseas continuar?", "title": "Reconfigurar ZHA" }, + "instruct_unplug": { + "description": "Tu antigua radio ha sido reiniciada. Si ya no necesitas el hardware, puedes desconectarlo ahora.", + "title": "Desconecta tu antigua radio" + }, + "intent_migrate": { + "description": "Tu antigua radio se restablecer\u00e1 de f\u00e1brica. Si est\u00e1s utilizando un adaptador combinado de Z-Wave y Zigbee como el HUSBZB-1, esto solo restablecer\u00e1 la parte de Zigbee. \n\n\u00bfDeseas continuar?", + "title": "Migrar a una nueva radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo de Radio" @@ -233,6 +243,14 @@ "description": "Tu copia de seguridad tiene una direcci\u00f3n IEEE diferente a la de tu radio. Para que tu red funcione correctamente, tambi\u00e9n debes cambiar la direcci\u00f3n IEEE de tu radio. \n\nEsta es una operaci\u00f3n permanente.", "title": "Sobrescribir la direcci\u00f3n IEEE de la radio" }, + "prompt_migrate_or_reconfigure": { + "description": "\u00bfEst\u00e1s migrando a una nueva radio o volviendo a configurar la radio actual?", + "menu_options": { + "intent_migrate": "Migrar a una nueva radio", + "intent_reconfigure": "Volver a configurar la radio actual" + }, + "title": "Migrar o volver a configurar" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Subir un archivo" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 6d39f4b653964b..54cabbe4c821e8 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -6,16 +6,64 @@ "usb_probe_failed": "USB seadme k\u00fcsitlemine eba\u00f5nnestus" }, "error": { - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "invalid_backup_json": "Sobimatu varukoopia JSON" }, "flow_title": "{name}", "step": { + "choose_automatic_backup": { + "data": { + "choose_automatic_backup": "Vali automaatne varundamine" + }, + "description": "Taasta v\u00f5rgu seaded automaatsest varukoopiast", + "title": "Taastamine automaatsest varukoopiast" + }, + "choose_formation_strategy": { + "description": "Vali raadio v\u00f5rguseaded.", + "menu_options": { + "choose_automatic_backup": "Taastamine automaatsest varukoopiast", + "form_new_network": "Kustuta v\u00f5rgu seaded ja moodusta uus v\u00f5rk", + "reuse_settings": "Raadiov\u00f5rgu s\u00e4tete s\u00e4ilitamine", + "upload_manual_backup": "Varukoopia \u00fcleslaadimine" + }, + "title": "V\u00f5rgu moodustamine" + }, + "choose_serial_port": { + "data": { + "path": "Jadaseadme tee" + }, + "description": "Vali Zigbee raadio jadaport", + "title": "Vali jadaport" + }, "confirm": { "description": "Kas soovid seadistada teenust {name} ?" }, "confirm_hardware": { "description": "Kas seadistada {name} ?" }, + "manual_pick_radio_type": { + "data": { + "radio_type": "Seadme raadio t\u00fc\u00fcp" + }, + "description": "Vali Zigbee raadio t\u00fc\u00fcp", + "title": "Seadme raadio t\u00fc\u00fcp" + }, + "manual_port_config": { + "data": { + "baudrate": "pordi kiirus", + "flow_control": "andmevoo juhtimine", + "path": "Jadaseadme tee" + }, + "description": "Sisesta jadapordi s\u00e4tted", + "title": "Jadapordi s\u00e4tted" + }, + "maybe_confirm_ezsp_restore": { + "data": { + "overwrite_coordinator_ieee": "Raadio IEEE-aadressi p\u00fcsiv asendamine" + }, + "description": "Varukoopial on erinev IEEE aadress kui raadiol. V\u00f5rgu n\u00f5uetekohaseks toimimiseks tuleks muuta ka raadio IEEE aadressi.\n\nSee on p\u00fcsiv toiming.", + "title": "Raadio IEEE aadressi \u00fclekirjutamine" + }, "pick_radio": { "data": { "radio_type": "Seadme raadio t\u00fc\u00fcp" @@ -32,6 +80,13 @@ "description": "Sisesta pordispetsiifilised seaded", "title": "Seaded" }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "Faili \u00fcleslaadimine" + }, + "description": "Taasta oma v\u00f5rgus\u00e4tted \u00fcleslaaditud JSON-varufailist. Saad selle alla laadida teisest ZHA paigaldusest **V\u00f5rguseaded** v\u00f5i kasutada Zigbee2MQTT 'coordinator_backup.json' faili.", + "title": "K\u00e4sitsi varundamise \u00fcleslaadimine" + }, "user": { "data": { "path": "Jadaseadme tee" @@ -61,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "K\u00f5igi LED-ide efekt", + "issue_individual_led_effect": "Efekt \u00fcksikute LEDide puhul", "squawk": "Pr\u00e4\u00e4ksata", "warn": "Hoiata" }, @@ -116,8 +173,22 @@ } }, "options": { + "abort": { + "not_zha_device": "See ei ole zha seade", + "single_instance_allowed": "Juba h\u00e4\u00e4lestatud. V\u00f5imalik on ainult \u00fcks sidumine.", + "usb_probe_failed": "USB seadme k\u00fcsitlemine nurjus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_backup_json": "Sobimatu JSON varundus kirje" + }, + "flow_title": "{name}", "step": { "choose_automatic_backup": { + "data": { + "choose_automatic_backup": "Vali automaatse varunduse kirje" + }, + "description": "Taasta v\u00f5rgus\u00e4tted automaatvarunduse kirjest", "title": "Taasta automaatvarundusest" }, "choose_formation_strategy": { @@ -141,6 +212,14 @@ "description": "ZHA peatatakse. Kas soovid j\u00e4tkata?", "title": "Seadista ZHA uuesti" }, + "instruct_unplug": { + "description": "Teie vana raadio on l\u00e4htestatud. Kui riistvara pole enam vaja, saad selle n\u00fc\u00fcd lahti \u00fchendada.", + "title": "\u00dchenda vana raadio lahti" + }, + "intent_migrate": { + "description": "Vana raadio l\u00e4htestatakse tehaseseadetele. Kui kasutad kombineeritud Z-Wave ja Zigbee adapterit, n\u00e4iteks HUSBZB-1, l\u00e4htestab see ainult Zigbee osa. \n\n Kas soovid j\u00e4tkata?", + "title": "Teisalda uuele seadmele" + }, "manual_pick_radio_type": { "data": { "radio_type": "Raadio t\u00fc\u00fcp" @@ -161,12 +240,22 @@ "data": { "overwrite_coordinator_ieee": "Asenda IEEE aadress j\u00e4\u00e4davalt" }, + "description": "Varukoopial on erinev IEEE aadress kui raadiol. V\u00f5rgu n\u00f5uetekohaseks toimimiseks tuleks muuta ka raadio IEEE aadressi.\n\nSee on p\u00fcsiv toiming.", "title": "Kirjuta IEEE aadress \u00fcle" }, + "prompt_migrate_or_reconfigure": { + "description": "Kas l\u00e4hed \u00fcle uuele raadiole v\u00f5i seadistad praegust raadiot \u00fcmber?", + "menu_options": { + "intent_migrate": "Teisalda uuele seadmele", + "intent_reconfigure": "Taasseadista praegune seade" + }, + "title": "Teisaldamine v\u00f5i uuesti seadistamine" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Lae kirje \u00fcles" }, + "description": "Taasta oma v\u00f5rgus\u00e4tted \u00fcleslaaditud JSON-varufailist. Saad selle alla laadida teisest ZHA paigaldusest **V\u00f5rguseaded** v\u00f5i kasutada Zigbee2MQTT 'coordinator_backup.json' faili.", "title": "Lae k\u00e4sitsi loodud varukoopia \u00fcles" } } diff --git a/homeassistant/components/zha/translations/he.json b/homeassistant/components/zha/translations/he.json index fa40de672e29ae..f48b30bd826c3a 100644 --- a/homeassistant/components/zha/translations/he.json +++ b/homeassistant/components/zha/translations/he.json @@ -11,6 +11,12 @@ "port_config": { "title": "\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea" }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05e7\u05d5\u05d1\u05e5" + }, + "title": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05d2\u05d9\u05d1\u05d5\u05d9 \u05d9\u05d3\u05e0\u05d9" + }, "user": { "title": "ZHA" } @@ -46,5 +52,22 @@ "device_dropped": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d4\u05d5\u05e9\u05de\u05d8", "device_offline": "\u05d4\u05ea\u05e7\u05df \u05dc\u05d0 \u05de\u05e7\u05d5\u05d5\u05df" } + }, + "options": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "flow_title": "{name}", + "step": { + "init": { + "title": "\u05d4\u05d2\u05d3\u05e8\u05d4 \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc ZHA" + }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05e7\u05d5\u05d1\u05e5" + }, + "title": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05d2\u05d9\u05d1\u05d5\u05d9 \u05d9\u05d3\u05e0\u05d9" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 9061246043a10f..b70bfcd597b16f 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effekt minden LED-re", + "issue_individual_led_effect": "Effekt egyes LED-ekre", "squawk": "Riaszt\u00e1s", "warn": "Figyelmeztet\u00e9s" }, @@ -210,6 +212,14 @@ "description": "A ZHA le\u00e1ll. Biztos benne, hogy folytatja?", "title": "A ZHA \u00fajrakonfigur\u00e1l\u00e1sa" }, + "instruct_unplug": { + "description": "A r\u00e9gi r\u00e1di\u00f3t vissza lett \u00e1ll\u00edtva Ha a hardverre m\u00e1r nincs sz\u00fcks\u00e9g, most kih\u00fazhatja.", + "title": "H\u00fazza ki a r\u00e9gi r\u00e1di\u00f3t" + }, + "intent_migrate": { + "description": "A r\u00e9gi r\u00e1di\u00f3ja gy\u00e1ri alaphelyzetbe ker\u00fcl. Ha kombin\u00e1lt Z-Wave \u00e9s Zigbee adaptert haszn\u00e1l, mint p\u00e9ld\u00e1ul a HUSBZB-1, akkor ez csak a Zigbee r\u00e9szt \u00e1ll\u00edtja vissza.\n\nSzeretn\u00e9 folytatni?", + "title": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s" + }, "manual_pick_radio_type": { "data": { "radio_type": "R\u00e1di\u00f3 t\u00edpusa" @@ -233,6 +243,14 @@ "description": "A biztons\u00e1gi m\u00e1solat IEEE-c\u00edme elt\u00e9r a r\u00e1di\u00f3\u00e9t\u00f3l. A h\u00e1l\u00f3zat megfelel\u0151 m\u0171k\u00f6d\u00e9s\u00e9hez a r\u00e1di\u00f3 IEEE-c\u00edm\u00e9t is meg kell v\u00e1ltoztatni. \n\n Ez egy v\u00e9gleles m\u0171velet.", "title": "A r\u00e1di\u00f3 IEEE-c\u00edm\u00e9nek fel\u00fcl\u00edr\u00e1sa" }, + "prompt_migrate_or_reconfigure": { + "description": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s vagy a jelenlegi r\u00e1di\u00f3 \u00fajrakonfigur\u00e1l\u00e1sa?", + "menu_options": { + "intent_migrate": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s", + "intent_reconfigure": "Az aktu\u00e1lis r\u00e1di\u00f3 \u00fajrakonfigur\u00e1l\u00e1sa" + }, + "title": "Migr\u00e1l\u00e1s vagy \u00fajrakonfigur\u00e1l\u00e1s" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "F\u00e1jl felt\u00f6lt\u00e9se" diff --git a/homeassistant/components/zha/translations/id.json b/homeassistant/components/zha/translations/id.json index 65f7588cb609d2..ab496b3be53248 100644 --- a/homeassistant/components/zha/translations/id.json +++ b/homeassistant/components/zha/translations/id.json @@ -210,6 +210,14 @@ "description": "ZHA akan dihentikan. Ingin melanjutkan?", "title": "Konfigurasi Ulang ZHA" }, + "instruct_unplug": { + "description": "Radio lama Anda telah disetel ulang. Jika perangkat keras tidak lagi diperlukan, Anda dapat mencabutnya sekarang.", + "title": "Cabut radio lama Anda" + }, + "intent_migrate": { + "description": "Radio lama Anda akan disetel ulang ke setelan pabrikan. Jika Anda menggunakan adaptor gabungan Z-Wave dan Zigbee seperti HUSBZB-1, ini hanya akan mengatur ulang bagian Zigbee.\n\nApakah Anda ingin melanjutkan?", + "title": "Migrasikan ke radio baru" + }, "manual_pick_radio_type": { "data": { "radio_type": "Jenis Radio" @@ -233,6 +241,14 @@ "description": "Cadangan Anda memiliki alamat IEEE yang berbeda dari radio Anda. Agar jaringan berfungsi dengan baik, alamat IEEE radio Anda juga harus diubah.\n\nOperasi ini bersifat permanen.", "title": "Timpa Alamat IEEE Radio" }, + "prompt_migrate_or_reconfigure": { + "description": "Apakah Anda memigrasikan ke radio baru atau mengkonfigurasi ulang radio yang sekarang?", + "menu_options": { + "intent_migrate": "Migrasikan ke radio baru", + "intent_reconfigure": "Mengkonfigurasi ulang radio yang sekarang" + }, + "title": "Migrasi atau konfigurasi ulang" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Unggah file" diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index 7ff1fc354baba6..02b3549d263c53 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effetto di emissione per tutti i LED", + "issue_individual_led_effect": "Effetto di emissione per i singoli LED", "squawk": "Strillare", "warn": "Avvertire" }, @@ -210,6 +212,14 @@ "description": "ZHA verr\u00e0 interrotto. Vuoi continuare?", "title": "Riconfigura ZHA" }, + "instruct_unplug": { + "description": "La tua vecchia radio \u00e8 stata ripristinata. Se l'hardware non \u00e8 pi\u00f9 necessario, ora \u00e8 possibile scollegarlo.", + "title": "Scollega la tua vecchia radio" + }, + "intent_migrate": { + "description": "La tua vecchia radio verr\u00e0 ripristinata alle impostazioni di fabbrica. Se stai usando un adattatore combinato Z-Wave e Zigbee come HUSBZB-1, questo ripristiner\u00e0 solo la parte Zigbee. \n\nVuoi continuare?", + "title": "Migra a una nuova radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo di radio" @@ -233,6 +243,14 @@ "description": "Il tuo backup ha un indirizzo IEEE diverso dalla tua radio. Affinch\u00e9 la rete funzioni correttamente, \u00e8 necessario modificare anche l'indirizzo IEEE della radio. \n\nQuesta \u00e8 un'operazione permanente.", "title": "Sovrascrivi indirizzo IEEE radio" }, + "prompt_migrate_or_reconfigure": { + "description": "Stai migrando a una nuova radio o riconfigurando la radio attuale?", + "menu_options": { + "intent_migrate": "Migra a una nuova radio", + "intent_reconfigure": "Riconfigura la radio attuale" + }, + "title": "Migrare o riconfigurare" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Carica un file" diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index dacddd201d9419..e408a9949bc9cb 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -6,19 +6,35 @@ "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_backup_json": "Ongeldige back-up-JSON" }, "flow_title": "{name}", "step": { "choose_automatic_backup": { "title": "Automatische back-up herstellen" }, + "choose_formation_strategy": { + "menu_options": { + "choose_automatic_backup": "Een automatische back-up herstellen", + "upload_manual_backup": "Upload een handmatige back-up" + } + }, + "choose_serial_port": { + "description": "Selecteer de seri\u00eble poort voor je Zigbee-radio", + "title": "Selecteer een seri\u00eble poort" + }, "confirm": { "description": "Wilt u {name} instellen?" }, "confirm_hardware": { "description": "Wilt u {name} instellen?" }, + "manual_port_config": { + "data": { + "baudrate": "poortsnelheid" + } + }, "pick_radio": { "data": { "radio_type": "Radio type" @@ -127,17 +143,33 @@ "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_backup_json": "Ongeldige back-up-JSON" }, "flow_title": "{name}", "step": { "choose_automatic_backup": { "title": "Automatische back-up herstellen" }, + "choose_formation_strategy": { + "menu_options": { + "choose_automatic_backup": "Een automatische back-up herstellen", + "upload_manual_backup": "Upload een handmatige back-up" + } + }, + "choose_serial_port": { + "description": "Selecteer de seri\u00eble poort voor je Zigbee-radio", + "title": "Selecteer een seri\u00eble poort" + }, "init": { "description": "ZHA wordt gestopt. Wilt u doorgaan?", "title": "ZHA opnieuw configureren" }, + "manual_port_config": { + "data": { + "baudrate": "poortsnelheid" + } + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Een bestand uploaden" diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index c469eb54bd7eb1..989409f7436ae8 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Utstedelseseffekt for alle lysdioder", + "issue_individual_led_effect": "Utstedelseseffekt for individuell LED", "squawk": "Squawk", "warn": "Advare" }, @@ -210,6 +212,14 @@ "description": "ZHA vil bli stoppet. \u00d8nsker du \u00e5 fortsette?", "title": "Konfigurer ZHA p\u00e5 nytt" }, + "instruct_unplug": { + "description": "Den gamle radioen din er tilbakestilt. Hvis maskinvaren ikke lenger er n\u00f8dvendig, kan du n\u00e5 koble den fra.", + "title": "Koble fra den gamle radioen" + }, + "intent_migrate": { + "description": "Den gamle radioen blir tilbakestilt til fabrikkstandard. Hvis du bruker en kombinert Z-Wave og Zigbee-adapter som HUSBZB-1, vil dette bare tilbakestille Zigbee-delen. \n\n \u00d8nsker du \u00e5 fortsette?", + "title": "Migrer til en ny radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radio type" @@ -233,6 +243,14 @@ "description": "Sikkerhetskopien din har en annen IEEE-adresse enn radioen din. For at nettverket skal fungere ordentlig, b\u00f8r IEEE-adressen til radioen ogs\u00e5 endres. \n\n Dette er en permanent operasjon.", "title": "Overskriv radio IEEE-adresse" }, + "prompt_migrate_or_reconfigure": { + "description": "Migrerer du til en ny radio eller rekonfigurerer den n\u00e5v\u00e6rende radioen?", + "menu_options": { + "intent_migrate": "Migrer til en ny radio", + "intent_reconfigure": "Konfigurer gjeldende radio p\u00e5 nytt" + }, + "title": "Migrer eller rekonfigurer" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Last opp en fil" diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 6ac69d568a7d34..b2046848f0abd6 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efekt dla wszystkich LED-\u00f3w", + "issue_individual_led_effect": "Efekt dla poszczeg\u00f3lnych LED-\u00f3w", "squawk": "squawk", "warn": "ostrze\u017cenie" }, @@ -210,6 +212,14 @@ "description": "ZHA zostanie zatrzymany. Czy chcesz kontynuowa\u0107?", "title": "Zmiana konfiguracji ZHA" }, + "instruct_unplug": { + "description": "Tw\u00f3j stary typ radia zosta\u0142 zresetowany. Je\u015bli sprz\u0119t nie jest ju\u017c potrzebny, mo\u017cesz go teraz od\u0142\u0105czy\u0107.", + "title": "Od\u0142\u0105cz stary typ radia" + }, + "intent_migrate": { + "description": "Twoje stare radio zostanie zresetowane do ustawie\u0144 fabrycznych. Je\u015bli u\u017cywasz po\u0142\u0105czonego adaptera Z-Wave i Zigbee, takiego jak HUSBZB-1, zresetuje to tylko cz\u0119\u015b\u0107 Zigbee. \n\nCzy chcesz kontynuowa\u0107?", + "title": "Migracja do nowego typu radia" + }, "manual_pick_radio_type": { "data": { "radio_type": "Typ radia" @@ -233,6 +243,14 @@ "description": "Twoja kopia zapasowa ma inny adres IEEE ni\u017c twoje radio. Aby sie\u0107 dzia\u0142a\u0142a prawid\u0142owo, nale\u017cy r\u00f3wnie\u017c zmieni\u0107 adres IEEE radia. \n\nTo jest trwa\u0142a operacja.", "title": "Nadpisanie adresu IEEE radia" }, + "prompt_migrate_or_reconfigure": { + "description": "Czy migrujesz do nowego typu radia czy ponownie konfigurujesz obecne radio?", + "menu_options": { + "intent_migrate": "Migracja do nowego", + "intent_reconfigure": "Ponowna konfiguracja" + }, + "title": "Migracja czy ponowna konfiguracja" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Prze\u015blij plik" diff --git a/homeassistant/components/zha/translations/pt-BR.json b/homeassistant/components/zha/translations/pt-BR.json index 2ec2b97438aa48..ba0ac930f1e67b 100644 --- a/homeassistant/components/zha/translations/pt-BR.json +++ b/homeassistant/components/zha/translations/pt-BR.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efeito de emiss\u00e3o para todos os LEDs", + "issue_individual_led_effect": "Efeito de emiss\u00e3o para LED individual", "squawk": "Squawk", "warn": "Aviso" }, @@ -210,6 +212,14 @@ "description": "ZHA ser\u00e1 interrompido. Voc\u00ea deseja continuar?", "title": "Reconfigurar ZHA" }, + "instruct_unplug": { + "description": "Seu r\u00e1dio antigo foi reiniciado. Se o hardware n\u00e3o for mais necess\u00e1rio, agora voc\u00ea pode desconect\u00e1-lo.", + "title": "Desconecte seu r\u00e1dio antigo" + }, + "intent_migrate": { + "description": "Seu r\u00e1dio antigo ser\u00e1 redefinido de f\u00e1brica. Se voc\u00ea estiver usando um adaptador Z-Wave e Zigbee combinado, como o HUSBZB-1, isso apenas redefinir\u00e1 a parte Zigbee. \n\n Voc\u00ea deseja continuar?", + "title": "Migrar para um novo r\u00e1dio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo de r\u00e1dio" @@ -233,6 +243,14 @@ "description": "Seu backup tem um endere\u00e7o IEEE diferente do seu r\u00e1dio. Para que sua rede funcione corretamente, o endere\u00e7o IEEE do seu r\u00e1dio tamb\u00e9m deve ser alterado. \n\n Esta \u00e9 uma opera\u00e7\u00e3o permanente.", "title": "Sobrescrever o endere\u00e7o IEEE do r\u00e1dio" }, + "prompt_migrate_or_reconfigure": { + "description": "Voc\u00ea est\u00e1 migrando para um novo r\u00e1dio ou reconfigurando o r\u00e1dio atual?", + "menu_options": { + "intent_migrate": "Migrar para um novo r\u00e1dio", + "intent_reconfigure": "Reconfigure o r\u00e1dio atual" + }, + "title": "Migrar ou reconfigurar" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Carregar um arquivo" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index fee8080d8eb8f3..a8e58f3ecd9fb0 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -210,6 +210,14 @@ "description": "\u0420\u0430\u0431\u043e\u0442\u0430 ZHA \u0431\u0443\u0434\u0435\u0442 \u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0430. \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c?", "title": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 ZHA" }, + "instruct_unplug": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f \u0431\u044b\u043b\u0438 \u0441\u0431\u0440\u043e\u0448\u0435\u043d\u044b. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043d\u0443\u0436\u043d\u043e, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0435\u0433\u043e.", + "title": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0440\u043e\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" + }, + "intent_migrate": { + "description": "\u0412\u0430\u0448 \u0441\u0442\u0430\u0440\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c \u0431\u0443\u0434\u0435\u0442 \u0441\u0431\u0440\u043e\u0448\u0435\u043d \u043a \u0437\u0430\u0432\u043e\u0434\u0441\u043a\u0438\u043c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c. \u0415\u0441\u043b\u0438 \u0412\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0435 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0430\u0434\u0430\u043f\u0442\u0435\u0440 Z-Wave \u0438 Zigbee (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 HUSBZB-1), \u0431\u0443\u0434\u0443\u0442 \u0441\u0431\u0440\u043e\u0448\u0435\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Zigbee.\n\n\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c?", + "title": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" @@ -233,6 +241,14 @@ "description": "\u0412 \u0412\u0430\u0448\u0435\u0439 \u0440\u0435\u0437\u0435\u0440\u0432\u043d\u043e\u0439 \u043a\u043e\u043f\u0438\u0438 IEEE-\u0430\u0434\u0440\u0435\u0441 \u043e\u0442\u043b\u0438\u0447\u0430\u0435\u0442\u0441\u044f \u043e\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0433\u043e \u0441\u0435\u0439\u0447\u0430\u0441. \u0427\u0442\u043e\u0431\u044b \u0412\u0430\u0448\u0430 \u0441\u0435\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043b\u0430 \u0434\u043e\u043b\u0436\u043d\u044b\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c, IEEE-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f \u0442\u0430\u043a\u0436\u0435 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0438\u0437\u043c\u0435\u043d\u0435\u043d. \n\n\u042d\u0442\u043e \u043d\u0435\u043e\u0431\u0440\u0430\u0442\u0438\u043c\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f.", "title": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u044c IEEE-\u0430\u0434\u0440\u0435\u0441\u0430 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" }, + "prompt_migrate_or_reconfigure": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439?", + "menu_options": { + "intent_migrate": "\u041f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c", + "intent_reconfigure": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c" + }, + "title": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0444\u0430\u0439\u043b" diff --git a/homeassistant/components/zha/translations/sv.json b/homeassistant/components/zha/translations/sv.json index ca9fc90f5e9e19..a95dc2970fcb9e 100644 --- a/homeassistant/components/zha/translations/sv.json +++ b/homeassistant/components/zha/translations/sv.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effekt f\u00f6r alla lysdioder", + "issue_individual_led_effect": "Effekt f\u00f6r enskilda lysdioder", "squawk": "Kraxa", "warn": "Varna" }, @@ -210,6 +212,14 @@ "description": "ZHA kommer att stoppas. Vill du forts\u00e4tta?", "title": "Konfigurera om ZHA" }, + "instruct_unplug": { + "description": "Din gamla radio har blivit fabriks\u00e5terst\u00e4lld. Om h\u00e5rdvaran inte l\u00e4ngre beh\u00f6vs kan du plugga ur den.", + "title": "Koppla ur din gamla radio" + }, + "intent_migrate": { + "description": "Din gamla radio blir fabriks\u00e5terst\u00e4lld. Om du anv\u00e4nder en kombinerad Z-Wave och Zigbee adapter som exempelvis HUSBZB-1, kommer enbart Zigbee delen \u00e5terst\u00e4llas.\n\nVill du forts\u00e4tta?", + "title": "Migrera till ny radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radiotyp" @@ -233,6 +243,14 @@ "description": "Din s\u00e4kerhetskopia har en annan IEEE-adress \u00e4n din radio. F\u00f6r att ditt n\u00e4tverk ska fungera korrekt b\u00f6r IEEE-adressen f\u00f6r din radio ocks\u00e5 \u00e4ndras. \n\n Detta \u00e4r en permanent \u00e5tg\u00e4rd.", "title": "Skriv \u00f6ver Radio IEEE-adress" }, + "prompt_migrate_or_reconfigure": { + "description": "Migrerar du till ny radio eller omkonfigurerar du nuvarande radio?", + "menu_options": { + "intent_migrate": "Migrera till ny radio", + "intent_reconfigure": "Omkonfigurera nuvarande radio" + }, + "title": "Migrera eller omkonfigurera" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Ladda upp en fil" diff --git a/homeassistant/components/zha/translations/tr.json b/homeassistant/components/zha/translations/tr.json index 3d3859f745c21f..f309ecf92a044e 100644 --- a/homeassistant/components/zha/translations/tr.json +++ b/homeassistant/components/zha/translations/tr.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "T\u00fcm LED'ler i\u00e7in sorun efekti", + "issue_individual_led_effect": "Bireysel LED i\u00e7in sorun efekti", "squawk": "Squawk", "warn": "Uyarmak" }, @@ -210,6 +212,14 @@ "description": "ZHA durdurulacak. Devam etmek istiyor musunuz?", "title": "ZHA'y\u0131 yeniden yap\u0131land\u0131r\u0131n" }, + "instruct_unplug": { + "description": "Eski radyonuz s\u0131f\u0131rland\u0131. Donan\u0131m art\u0131k gerekli de\u011filse, \u015fimdi \u00e7\u0131kartabilirsiniz.", + "title": "Eski radyonuzu \u00e7\u0131kart\u0131n" + }, + "intent_migrate": { + "description": "Eski radyonuz fabrika ayarlar\u0131na s\u0131f\u0131rlanacak. HUSBZB-1 gibi birle\u015fik bir Z-Wave ve Zigbee adapt\u00f6r\u00fc kullan\u0131yorsan\u0131z, bu yaln\u0131zca Zigbee k\u0131sm\u0131n\u0131 s\u0131f\u0131rlayacakt\u0131r. \n\n Devam etmek istiyor musunuz?", + "title": "Yeni bir radyoya ge\u00e7i\u015f yap\u0131n" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radyo Tipi" @@ -233,6 +243,14 @@ "description": "Yedeklemenizin, telsizinizden farkl\u0131 bir IEEE adresi var. A\u011f\u0131n\u0131z\u0131n d\u00fczg\u00fcn \u00e7al\u0131\u015fmas\u0131 i\u00e7in telsizinizin IEEE adresinin de de\u011fi\u015ftirilmesi gerekir. \n\n Bu kal\u0131c\u0131 bir operasyondur.", "title": "Radyo IEEE Adresinin \u00dczerine Yaz" }, + "prompt_migrate_or_reconfigure": { + "description": "Yeni bir radyoya m\u0131 ge\u00e7iyorsunuz yoksa mevcut radyoyu yeniden mi yap\u0131land\u0131r\u0131yorsunuz?", + "menu_options": { + "intent_migrate": "Yeni bir radyoya ge\u00e7i\u015f yap\u0131n", + "intent_reconfigure": "Mevcut radyoyu yeniden yap\u0131land\u0131r\u0131n" + }, + "title": "Ta\u015f\u0131ma veya yeniden yap\u0131land\u0131rma" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Bir dosya y\u00fckleyin" diff --git a/homeassistant/components/zha/translations/zh-Hans.json b/homeassistant/components/zha/translations/zh-Hans.json index ab4b69efbb0318..c2b8d9f7cc2f9a 100644 --- a/homeassistant/components/zha/translations/zh-Hans.json +++ b/homeassistant/components/zha/translations/zh-Hans.json @@ -87,5 +87,20 @@ "remote_button_short_release": "\"{subtype}\" \u677e\u5f00", "remote_button_triple_press": "\"{subtype}\" \u4e09\u8fde\u51fb" } + }, + "options": { + "step": { + "intent_migrate": { + "title": "\u8fc1\u79fb\u5230\u65b0\u7684\u65e0\u7ebf\u7535\u8bbe\u7f6e" + }, + "prompt_migrate_or_reconfigure": { + "description": "\u60a8\u662f\u5426\u6b63\u5728\u8fc1\u79fb\u5230\u65b0\u65e0\u7ebf\u7535\u6216\u91cd\u65b0\u914d\u7f6e\u5f53\u524d\u65e0\u7ebf\u7535\uff1f", + "menu_options": { + "intent_migrate": "\u8fc1\u79fb\u5230\u65b0\u7684\u65e0\u7ebf\u7535\u8bbe\u7f6e", + "intent_reconfigure": "\u91cd\u65b0\u914d\u7f6e\u5f53\u524d\u65e0\u7ebf\u7535" + }, + "title": "\u8fc1\u79fb\u6216\u91cd\u65b0\u914d\u7f6e" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index 0fbd233bc60f7c..23d64c8cc4240c 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "\u57f7\u884c\u5168\u90e8 LED \u6548\u679c", + "issue_individual_led_effect": "\u57f7\u884c\u500b\u5225 LED \u6548\u679c", "squawk": "\u61c9\u7b54", "warn": "\u8b66\u544a" }, @@ -210,6 +212,14 @@ "description": "ZHA \u5c07\u505c\u6b62\u3001\u662f\u5426\u8981\u7e7c\u7e8c\uff1f", "title": "\u91cd\u65b0\u8a2d\u5b9a ZHA" }, + "instruct_unplug": { + "description": "\u820a\u7121\u7dda\u96fb\u5df2\u7d93\u91cd\u7f6e\uff0c\u5047\u5982\u786c\u9ad4\u4e0d\u518d\u4f7f\u7528\u3001\u53ef\u4ee5\u9032\u884c\u79fb\u9664\u3002", + "title": "\u79fb\u9664\u820a\u7121\u7dda\u96fb" + }, + "intent_migrate": { + "description": "\u820a\u7121\u7dda\u96fb\u5c07\u6703\u9032\u884c\u91cd\u7f6e\u3002\u5047\u5982\u4f7f\u7528\u7684\u9069\u914d\u5668\u70ba\u985e\u4f3c\u65bc HUSBZB-1 \u7684 Z-Wave \u8207 Zigbee \u8907\u5408\u88dd\u7f6e\uff0c\u5c07\u50c5\u6703\u91cd\u7f6e Zigbee \u90e8\u5206\u3002\n\n\u662f\u5426\u8981\u7e7c\u7e8c\uff1f", + "title": "\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u7121\u7dda\u96fb\u985e\u5225" @@ -233,6 +243,14 @@ "description": "\u5099\u4efd\u4e2d\u7684 IEEE \u4f4d\u5740\u8207\u73fe\u6709\u7121\u7dda\u96fb\u4e0d\u540c\u3002\u70ba\u4e86\u78ba\u8a8d\u7db2\u8def\u6b63\u5e38\u5de5\u4f5c\uff0c\u7121\u7dda\u96fb\u7684 IEEE \u4f4d\u5740\u5fc5\u9808\u9032\u884c\u8b8a\u66f4\u3002\n\n\u6b64\u70ba\u6c38\u4e45\u6027\u64cd\u4f5c\u3002.", "title": "\u8986\u5beb\u7121\u7dda\u96fb IEEE \u4f4d\u5740" }, + "prompt_migrate_or_reconfigure": { + "description": "\u8981\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb\u6216\u91cd\u65b0\u8a2d\u5b9a\u76ee\u524d\u7121\u7dda\u96fb\uff1f", + "menu_options": { + "intent_migrate": "\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb", + "intent_reconfigure": "\u91cd\u65b0\u8a2d\u5b9a\u76ee\u524d\u7121\u7dda\u96fb" + }, + "title": "\u9077\u79fb\u6216\u91cd\u65b0\u8a2d\u5b9a" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u4e0a\u50b3\u6a94\u6848" diff --git a/homeassistant/components/zone/manifest.json b/homeassistant/components/zone/manifest.json index 019049a3b715e3..fe039817c64947 100644 --- a/homeassistant/components/zone/manifest.json +++ b/homeassistant/components/zone/manifest.json @@ -4,5 +4,6 @@ "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/zone", "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" + "quality_scale": "internal", + "integration_type": "system" } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f8828e8cdd0e28..cab07f4287f9cb 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -142,7 +142,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.connect() except InvalidServerVersion as err: if use_addon: - async_ensure_addon_updated(hass) + addon_manager = _get_addon_manager(hass) + addon_manager.async_schedule_update_addon(catch_error=True) else: async_create_issue( hass, @@ -205,8 +206,7 @@ async def handle_ha_shutdown(event: Event) -> None: LOGGER.info("Connection to Zwave JS Server initialized") - if client.driver is None: - raise RuntimeError("Driver not ready.") + assert client.driver await driver_events.setup(client.driver) @@ -789,17 +789,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: info = hass.data[DOMAIN][entry.entry_id] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] - tasks: list[asyncio.Task | Coroutine] = [] - for platform, task in driver_events.platform_setup_tasks.items(): - if task.done(): - tasks.append( - hass.config_entries.async_forward_entry_unload(entry, platform) - ) - else: - task.cancel() - tasks.append(task) + tasks: list[Coroutine] = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform, task in driver_events.platform_setup_tasks.items() + if not task.cancel() + ] - unload_ok = all(await asyncio.gather(*tasks)) + unload_ok = all(await asyncio.gather(*tasks)) if tasks else True if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) @@ -842,9 +838,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" - addon_manager: AddonManager = get_addon_manager(hass) - if addon_manager.task_in_progress(): - raise ConfigEntryNotReady + addon_manager = _get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() except AddonError as err: @@ -860,24 +854,24 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") addon_state = addon_info.state + addon_config = { + CONF_ADDON_DEVICE: usb_path, + CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, + } + if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, catch_error=True, ) raise ConfigEntryNotReady if addon_state == AddonState.NOT_RUNNING: addon_manager.async_schedule_setup_addon( - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, catch_error=True, ) raise ConfigEntryNotReady @@ -911,9 +905,9 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> @callback -def async_ensure_addon_updated(hass: HomeAssistant) -> None: +def _get_addon_manager(hass: HomeAssistant) -> AddonManager: """Ensure that Z-Wave JS add-on is updated and running.""" addon_manager: AddonManager = get_addon_manager(hass) if addon_manager.task_in_progress(): raise ConfigEntryNotReady - addon_manager.async_schedule_update_addon(catch_error=True) + return addon_manager diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 610fc850e90aa7..3e27235ef84823 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -5,10 +5,10 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from enum import Enum -from functools import partial +from functools import partial, wraps from typing import Any, TypeVar -from typing_extensions import ParamSpec +from typing_extensions import Concatenate, ParamSpec from homeassistant.components.hassio import ( async_create_backup, @@ -28,17 +28,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton -from .const import ( - ADDON_SLUG, - CONF_ADDON_DEVICE, - CONF_ADDON_S0_LEGACY_KEY, - CONF_ADDON_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_S2_AUTHENTICATED_KEY, - CONF_ADDON_S2_UNAUTHENTICATED_KEY, - DOMAIN, - LOGGER, -) +from .const import ADDON_SLUG, DOMAIN, LOGGER +_AddonManagerT = TypeVar("_AddonManagerT", bound="AddonManager") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -49,25 +41,33 @@ @callback def get_addon_manager(hass: HomeAssistant) -> AddonManager: """Get the add-on manager.""" - return AddonManager(hass) + return AddonManager(hass, "Z-Wave JS", ADDON_SLUG) def api_error( error_message: str, -) -> Callable[[Callable[_P, Awaitable[_R]]], Callable[_P, Coroutine[Any, Any, _R]]]: +) -> Callable[ + [Callable[Concatenate[_AddonManagerT, _P], Awaitable[_R]]], + Callable[Concatenate[_AddonManagerT, _P], Coroutine[Any, Any, _R]], +]: """Handle HassioAPIError and raise a specific AddonError.""" def handle_hassio_api_error( - func: Callable[_P, Awaitable[_R]] - ) -> Callable[_P, Coroutine[Any, Any, _R]]: + func: Callable[Concatenate[_AddonManagerT, _P], Awaitable[_R]] + ) -> Callable[Concatenate[_AddonManagerT, _P], Coroutine[Any, Any, _R]]: """Handle a HassioAPIError.""" - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + @wraps(func) + async def wrapper( + self: _AddonManagerT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap an add-on manager method.""" try: - return_value = await func(*args, **kwargs) + return_value = await func(self, *args, **kwargs) except HassioAPIError as err: - raise AddonError(f"{error_message}: {err}") from err + raise AddonError( + f"{error_message.format(addon_name=self.addon_name)}: {err}" + ) from err return return_value @@ -100,12 +100,14 @@ class AddonManager: """Manage the add-on. Methods may raise AddonError. - Only one instance of this class may exist + Only one instance of this class may exist per add-on to keep track of running add-on tasks. """ - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, addon_name: str, addon_slug: str) -> None: """Set up the add-on manager.""" + self.addon_name = addon_name + self.addon_slug = addon_slug self._hass = hass self._install_task: asyncio.Task | None = None self._restart_task: asyncio.Task | None = None @@ -123,21 +125,23 @@ def task_in_progress(self) -> bool: ) ) - @api_error("Failed to get Z-Wave JS add-on discovery info") + @api_error("Failed to get {addon_name} add-on discovery info") async def async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" - discovery_info = await async_get_addon_discovery_info(self._hass, ADDON_SLUG) + discovery_info = await async_get_addon_discovery_info( + self._hass, self.addon_slug + ) if not discovery_info: - raise AddonError("Failed to get Z-Wave JS add-on discovery info") + raise AddonError(f"Failed to get {self.addon_name} add-on discovery info") discovery_info_config: dict = discovery_info["config"] return discovery_info_config - @api_error("Failed to get the Z-Wave JS add-on info") + @api_error("Failed to get the {addon_name} add-on info") async def async_get_addon_info(self) -> AddonInfo: - """Return and cache Z-Wave JS add-on info.""" - addon_store_info = await async_get_addon_store_info(self._hass, ADDON_SLUG) + """Return and cache manager add-on info.""" + addon_store_info = await async_get_addon_store_info(self._hass, self.addon_slug) LOGGER.debug("Add-on store info: %s", addon_store_info) if not addon_store_info["installed"]: return AddonInfo( @@ -147,7 +151,7 @@ async def async_get_addon_info(self) -> AddonInfo: version=None, ) - addon_info = await async_get_addon_info(self._hass, ADDON_SLUG) + addon_info = await async_get_addon_info(self._hass, self.addon_slug) addon_state = self.async_get_addon_state(addon_info) return AddonInfo( options=addon_info["options"], @@ -158,7 +162,7 @@ async def async_get_addon_info(self) -> AddonInfo: @callback def async_get_addon_state(self, addon_info: dict[str, Any]) -> AddonState: - """Return the current state of the Z-Wave JS add-on.""" + """Return the current state of the managed add-on.""" addon_state = AddonState.NOT_RUNNING if addon_info["state"] == "started": @@ -170,25 +174,27 @@ def async_get_addon_state(self, addon_info: dict[str, Any]) -> AddonState: return addon_state - @api_error("Failed to set the Z-Wave JS add-on options") + @api_error("Failed to set the {addon_name} add-on options") async def async_set_addon_options(self, config: dict) -> None: - """Set Z-Wave JS add-on options.""" + """Set manager add-on options.""" options = {"options": config} - await async_set_addon_options(self._hass, ADDON_SLUG, options) + await async_set_addon_options(self._hass, self.addon_slug, options) - @api_error("Failed to install the Z-Wave JS add-on") + @api_error("Failed to install the {addon_name} add-on") async def async_install_addon(self) -> None: - """Install the Z-Wave JS add-on.""" - await async_install_addon(self._hass, ADDON_SLUG) + """Install the managed add-on.""" + await async_install_addon(self._hass, self.addon_slug) @callback def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that installs the Z-Wave JS add-on. + """Schedule a task that installs the managed add-on. Only schedule a new install task if the there's no running task. """ if not self._install_task or self._install_task.done(): - LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + LOGGER.info( + "%s add-on is not installed. Installing add-on", self.addon_name + ) self._install_task = self._async_schedule_addon_operation( self.async_install_addon, catch_error=catch_error ) @@ -197,85 +203,79 @@ def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Tas @callback def async_schedule_install_setup_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], catch_error: bool = False, ) -> asyncio.Task: - """Schedule a task that installs and sets up the Z-Wave JS add-on. + """Schedule a task that installs and sets up the managed add-on. Only schedule a new install task if the there's no running task. """ if not self._install_task or self._install_task.done(): - LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + LOGGER.info( + "%s add-on is not installed. Installing add-on", self.addon_name + ) self._install_task = self._async_schedule_addon_operation( self.async_install_addon, partial( self.async_configure_addon, - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, ), self.async_start_addon, catch_error=catch_error, ) return self._install_task - @api_error("Failed to uninstall the Z-Wave JS add-on") + @api_error("Failed to uninstall the {addon_name} add-on") async def async_uninstall_addon(self) -> None: - """Uninstall the Z-Wave JS add-on.""" - await async_uninstall_addon(self._hass, ADDON_SLUG) + """Uninstall the managed add-on.""" + await async_uninstall_addon(self._hass, self.addon_slug) - @api_error("Failed to update the Z-Wave JS add-on") + @api_error("Failed to update the {addon_name} add-on") async def async_update_addon(self) -> None: - """Update the Z-Wave JS add-on if needed.""" + """Update the managed add-on if needed.""" addon_info = await self.async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: - raise AddonError("Z-Wave JS add-on is not installed") + raise AddonError(f"{self.addon_name} add-on is not installed") if not addon_info.update_available: return await self.async_create_backup() - await async_update_addon(self._hass, ADDON_SLUG) + await async_update_addon(self._hass, self.addon_slug) @callback def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that updates and sets up the Z-Wave JS add-on. + """Schedule a task that updates and sets up the managed add-on. Only schedule a new update task if the there's no running task. """ if not self._update_task or self._update_task.done(): - LOGGER.info("Trying to update the Z-Wave JS add-on") + LOGGER.info("Trying to update the %s add-on", self.addon_name) self._update_task = self._async_schedule_addon_operation( self.async_update_addon, catch_error=catch_error, ) return self._update_task - @api_error("Failed to start the Z-Wave JS add-on") + @api_error("Failed to start the {addon_name} add-on") async def async_start_addon(self) -> None: - """Start the Z-Wave JS add-on.""" - await async_start_addon(self._hass, ADDON_SLUG) + """Start the managed add-on.""" + await async_start_addon(self._hass, self.addon_slug) - @api_error("Failed to restart the Z-Wave JS add-on") + @api_error("Failed to restart the {addon_name} add-on") async def async_restart_addon(self) -> None: - """Restart the Z-Wave JS add-on.""" - await async_restart_addon(self._hass, ADDON_SLUG) + """Restart the managed add-on.""" + await async_restart_addon(self._hass, self.addon_slug) @callback def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that starts the Z-Wave JS add-on. + """Schedule a task that starts the managed add-on. Only schedule a new start task if the there's no running task. """ if not self._start_task or self._start_task.done(): - LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + LOGGER.info("%s add-on is not running. Starting add-on", self.addon_name) self._start_task = self._async_schedule_addon_operation( self.async_start_addon, catch_error=catch_error ) @@ -283,87 +283,67 @@ def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task: @callback def async_schedule_restart_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that restarts the Z-Wave JS add-on. + """Schedule a task that restarts the managed add-on. Only schedule a new restart task if the there's no running task. """ if not self._restart_task or self._restart_task.done(): - LOGGER.info("Restarting Z-Wave JS add-on") + LOGGER.info("Restarting %s add-on", self.addon_name) self._restart_task = self._async_schedule_addon_operation( self.async_restart_addon, catch_error=catch_error ) return self._restart_task - @api_error("Failed to stop the Z-Wave JS add-on") + @api_error("Failed to stop the {addon_name} add-on") async def async_stop_addon(self) -> None: - """Stop the Z-Wave JS add-on.""" - await async_stop_addon(self._hass, ADDON_SLUG) + """Stop the managed add-on.""" + await async_stop_addon(self._hass, self.addon_slug) async def async_configure_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], ) -> None: - """Configure and start Z-Wave JS add-on.""" + """Configure and start manager add-on.""" addon_info = await self.async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: - raise AddonError("Z-Wave JS add-on is not installed") - - new_addon_options = { - CONF_ADDON_DEVICE: usb_path, - CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, - } + raise AddonError(f"{self.addon_name} add-on is not installed") - if new_addon_options != addon_info.options: - await self.async_set_addon_options(new_addon_options) + if addon_config != addon_info.options: + await self.async_set_addon_options(addon_config) @callback def async_schedule_setup_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], catch_error: bool = False, ) -> asyncio.Task: - """Schedule a task that configures and starts the Z-Wave JS add-on. + """Schedule a task that configures and starts the managed add-on. - Only schedule a new setup task if the there's no running task. + Only schedule a new setup task if there's no running task. """ if not self._start_task or self._start_task.done(): - LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + LOGGER.info("%s add-on is not running. Starting add-on", self.addon_name) self._start_task = self._async_schedule_addon_operation( partial( self.async_configure_addon, - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, ), self.async_start_addon, catch_error=catch_error, ) return self._start_task - @api_error("Failed to create a backup of the Z-Wave JS add-on.") + @api_error("Failed to create a backup of the {addon_name} add-on.") async def async_create_backup(self) -> None: - """Create a partial backup of the Z-Wave JS add-on.""" + """Create a partial backup of the managed add-on.""" addon_info = await self.async_get_addon_info() - name = f"addon_{ADDON_SLUG}_{addon_info.version}" + name = f"addon_{self.addon_slug}_{addon_info.version}" LOGGER.debug("Creating backup: %s", name) await async_create_backup( self._hass, - {"name": name, "addons": [ADDON_SLUG]}, + {"name": name, "addons": [self.addon_slug]}, partial=True, ) @@ -388,4 +368,4 @@ async def addon_operation() -> None: class AddonError(HomeAssistantError): - """Represent an error with Z-Wave JS add-on.""" + """Represent an error with the managed add-on.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 4a5b233a2f0311..890df4add48199 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -448,7 +448,7 @@ def async_register_api(hass: HomeAssistant) -> None: ) @websocket_api.async_response async def websocket_network_status( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get the status of the Z-Wave JS network.""" if ENTRY_ID in msg: @@ -518,7 +518,7 @@ async def websocket_network_status( async def websocket_subscribe_node_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subscribe to node status update events of a Z-Wave JS node.""" @@ -559,7 +559,7 @@ def async_cleanup() -> None: async def websocket_node_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the status of a Z-Wave JS node.""" @@ -577,7 +577,7 @@ async def websocket_node_status( async def websocket_node_metadata( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the metadata of a Z-Wave JS node.""" @@ -607,7 +607,7 @@ async def websocket_node_metadata( async def websocket_node_comments( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the comments of a Z-Wave JS node.""" @@ -648,7 +648,7 @@ async def websocket_node_comments( async def websocket_add_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -791,7 +791,7 @@ def device_registered(device: dr.DeviceEntry) -> None: async def websocket_grant_security_classes( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -819,7 +819,7 @@ async def websocket_grant_security_classes( async def websocket_validate_dsk_and_enter_pin( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -849,7 +849,7 @@ async def websocket_validate_dsk_and_enter_pin( async def websocket_provision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -902,7 +902,7 @@ async def websocket_provision_smart_start_node( async def websocket_unprovision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -935,7 +935,7 @@ async def websocket_unprovision_smart_start_node( async def websocket_get_provisioning_entries( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -961,7 +961,7 @@ async def websocket_get_provisioning_entries( async def websocket_parse_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -987,7 +987,7 @@ async def websocket_parse_qr_code_string( async def websocket_supports_feature( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1013,7 +1013,7 @@ async def websocket_supports_feature( async def websocket_stop_inclusion( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1040,7 +1040,7 @@ async def websocket_stop_inclusion( async def websocket_stop_exclusion( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1068,7 +1068,7 @@ async def websocket_stop_exclusion( async def websocket_remove_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1147,7 +1147,7 @@ def node_removed(event: dict) -> None: async def websocket_replace_failed_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Replace a failed node with a new node.""" @@ -1298,7 +1298,7 @@ def device_registered(device: dr.DeviceEntry) -> None: async def websocket_remove_failed_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Remove a failed node from the Z-Wave network.""" @@ -1342,7 +1342,7 @@ def node_removed(event: dict) -> None: async def websocket_begin_healing_network( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1369,7 +1369,7 @@ async def websocket_begin_healing_network( async def websocket_subscribe_heal_network_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1413,7 +1413,7 @@ def forward_event(key: str, event: dict) -> None: async def websocket_stop_healing_network( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1440,7 +1440,7 @@ async def websocket_stop_healing_network( async def websocket_heal_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Heal a node on the Z-Wave network.""" @@ -1468,7 +1468,7 @@ async def websocket_heal_node( async def websocket_refresh_node_info( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Re-interview a node.""" @@ -1518,7 +1518,7 @@ def forward_stage(event: dict) -> None: async def websocket_refresh_node_values( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Refresh node values.""" @@ -1540,7 +1540,7 @@ async def websocket_refresh_node_values( async def websocket_refresh_node_cc_values( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Refresh node values for a particular CommandClass.""" @@ -1574,7 +1574,7 @@ async def websocket_refresh_node_cc_values( async def websocket_set_config_parameter( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Set a config parameter value for a Z-Wave node.""" @@ -1619,7 +1619,7 @@ async def websocket_set_config_parameter( @websocket_api.async_response @async_get_node async def websocket_get_config_parameters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, node: Node + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], node: Node ) -> None: """Get a list of configuration parameters for a Z-Wave node.""" values = node.get_configuration_values() @@ -1671,7 +1671,7 @@ def filename_is_present_if_logging_to_file(obj: dict) -> dict: async def websocket_subscribe_log_updates( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1758,7 +1758,7 @@ def log_config_updates(event: dict) -> None: async def websocket_update_log_config( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1782,7 +1782,7 @@ async def websocket_update_log_config( async def websocket_get_log_config( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1809,7 +1809,7 @@ async def websocket_get_log_config( async def websocket_update_data_collection_preference( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1841,7 +1841,7 @@ async def websocket_update_data_collection_preference( async def websocket_data_collection_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1868,7 +1868,7 @@ async def websocket_data_collection_status( async def websocket_abort_firmware_update( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Abort a firmware update.""" @@ -1889,7 +1889,7 @@ async def websocket_abort_firmware_update( async def websocket_is_node_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get whether firmware update is in progress for given node.""" @@ -1921,7 +1921,7 @@ def _get_firmware_update_progress_dict( async def websocket_subscribe_firmware_update_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subscribe to the status of a firmware update.""" @@ -1994,7 +1994,7 @@ def forward_finished(event: dict) -> None: async def websocket_get_firmware_update_capabilities( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Abort a firmware update.""" @@ -2015,7 +2015,7 @@ async def websocket_get_firmware_update_capabilities( async def websocket_is_any_ota_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2092,7 +2092,7 @@ async def post(self, request: web.Request, device_id: str) -> web.Response: async def websocket_check_for_config_updates( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2121,7 +2121,7 @@ async def websocket_check_for_config_updates( async def websocket_install_config_update( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2160,7 +2160,7 @@ def _get_controller_statistics_dict( async def websocket_subscribe_controller_statistics( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2257,7 +2257,7 @@ def _convert_node_to_device_id(node: Node) -> str: async def websocket_subscribe_node_statistics( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subsribe to the statistics updates for a node.""" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index e2bd69a143602d..6cbb1ea3016c1c 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -188,7 +188,7 @@ def __init__( ) self._set_modes_and_presets() self._attr_supported_features = 0 - if len(self._hvac_presets) > 1: + if self._current_mode and len(self._hvac_presets) > 1: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE # If any setpoint value exists, we can assume temperature # can be set @@ -428,9 +428,7 @@ def max_temp(self) -> float: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if not self._fan_mode: - return - + assert self._fan_mode is not None try: new_state = int( next( @@ -484,9 +482,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" - if self._current_mode is None: - # Thermostat(valve) has no support for setting a mode, so we make it a no-op - return + assert self._current_mode is not None if preset_mode == PRESET_NONE: # try to restore to the (translated) main hvac mode await self.async_set_hvac_mode(self.hvac_mode) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index c114662888fca2..0a084b3a309533 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -29,6 +29,7 @@ from . import disconnect_client from .addon import AddonError, AddonInfo, AddonManager, AddonState, get_addon_manager from .const import ( + ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_EMULATE_HARDWARE, CONF_ADDON_LOG_LEVEL, @@ -125,15 +126,20 @@ def get_usb_ports() -> dict[str, str]: ports = list_ports.comports() port_descriptions = {} for port in ports: - usb_device = usb.usb_device_from_port(port) - dev_path = usb.get_serial_by_id(usb_device.device) + vid: str | None = None + pid: str | None = None + if port.vid is not None and port.pid is not None: + usb_device = usb.usb_device_from_port(port) + vid = usb_device.vid + pid = usb_device.pid + dev_path = usb.get_serial_by_id(port.device) human_name = usb.human_readable_device_name( dev_path, - usb_device.serial_number, - usb_device.manufacturer, - usb_device.description, - usb_device.vid, - usb_device.pid, + port.serial_number, + port.manufacturer, + port.description, + vid, + pid, ) port_descriptions[dev_path] = human_name return port_descriptions @@ -492,6 +498,9 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResu if self._async_in_progress(): return self.async_abort(reason="already_in_progress") + if discovery_info.slug != ADDON_SLUG: + return self.async_abort(reason="not_zwave_js_addon") + self.ws_address = ( f"ws://{discovery_info.config['host']}:{discovery_info.config['port']}" ) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 30364d127eb745..b3f3aeaf1c0481 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -27,7 +27,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -138,8 +137,7 @@ def current_cover_position(self) -> int | None: async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value( target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) ) @@ -147,15 +145,13 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value(target_value, 99) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value(target_value, 0) async def async_stop_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index ef34a2f12de99f..068be7feb0b2ae 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -2,7 +2,6 @@ from __future__ import annotations from copy import deepcopy -from dataclasses import astuple, dataclass from typing import Any from zwave_js_server.client import Client @@ -21,27 +20,13 @@ from .const import DATA_CLIENT, DOMAIN, USER_AGENT from .helpers import ( + ZwaveValueMatcher, get_home_and_node_id_from_device_entry, get_state_key_from_unique_id, get_value_id_from_unique_id, + value_matches_matcher, ) - -@dataclass -class ZwaveValueMatcher: - """Class to allow matching a Z-Wave Value.""" - - property_: str | int | None = None - command_class: int | None = None - endpoint: int | None = None - property_key: str | int | None = None - - def __post_init__(self) -> None: - """Post initialization check.""" - if all(val is None for val in astuple(self)): - raise ValueError("At least one of the fields must be set.") - - KEYS_TO_REDACT = {"homeId", "location"} VALUES_TO_REDACT = ( @@ -55,21 +40,7 @@ def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: if zwave_value.get("value") in (None, ""): return zwave_value for value_to_redact in VALUES_TO_REDACT: - command_class = None - if "commandClass" in zwave_value: - command_class = CommandClass(zwave_value["commandClass"]) - zwave_value_id = ZwaveValueMatcher( - property_=zwave_value.get("property"), - command_class=command_class, - endpoint=zwave_value.get("endpoint"), - property_key=zwave_value.get("propertyKey"), - ) - if all( - redacted_field_val is None or redacted_field_val == zwave_value_field_val - for redacted_field_val, zwave_value_field_val in zip( - astuple(value_to_redact), astuple(zwave_value_id) - ) - ): + if value_matches_matcher(value_to_redact, zwave_value): redacted_value: ValueDataType = deepcopy(zwave_value) redacted_value["value"] = REDACTED return redacted_value diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 9ae1cd36d136a3..5b20572c2d845b 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -109,8 +109,6 @@ PERCENTAGE, POWER_BTU_PER_HOUR, POWER_WATT, - PRECIPITATION_INCHES_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_INHG, PRESSURE_MMHG, PRESSURE_PSI, @@ -127,6 +125,7 @@ VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, VOLUME_GALLONS, VOLUME_LITERS, + UnitOfVolumetricFlux, ) from .const import ( @@ -201,14 +200,14 @@ VOLUME_GALLONS: UNIT_GALLONS, FREQUENCY_HERTZ: UNIT_HERTZ, PRESSURE_INHG: UNIT_INCHES_OF_MERCURY, - PRECIPITATION_INCHES_PER_HOUR: UNIT_INCHES_PER_HOUR, + UnitOfVolumetricFlux.INCHES_PER_HOUR: UNIT_INCHES_PER_HOUR, MASS_KILOGRAMS: UNIT_KILOGRAM, FREQUENCY_KILOHERTZ: UNIT_KILOHERTZ, VOLUME_LITERS: UNIT_LITER, LIGHT_LUX: UNIT_LUX, LENGTH_METERS: UNIT_METER, ELECTRIC_CURRENT_MILLIAMPERE: UNIT_MILLIAMPERE, - PRECIPITATION_MILLIMETERS_PER_HOUR: UNIT_MILLIMETER_HOUR, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: UNIT_MILLIMETER_HOUR, ELECTRIC_POTENTIAL_MILLIVOLT: UNIT_MILLIVOLT, SPEED_MILES_PER_HOUR: UNIT_MPH, SPEED_METERS_PER_SECOND: UNIT_M_S, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6949f3654a58fd..792bd4fc1b164b 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -2,18 +2,19 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import astuple, dataclass import logging from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.const import CommandClass, ConfigurationValueType from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, Value as ZwaveValue, + ValueDataType, get_value_id_str, ) @@ -55,6 +56,42 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass +class ZwaveValueMatcher: + """Class to allow matching a Z-Wave Value.""" + + property_: str | int | None = None + command_class: int | None = None + endpoint: int | None = None + property_key: str | int | None = None + + def __post_init__(self) -> None: + """Post initialization check.""" + if all(val is None for val in astuple(self)): + raise ValueError("At least one of the fields must be set.") + + +def value_matches_matcher( + matcher: ZwaveValueMatcher, value_data: ValueDataType +) -> bool: + """Return whether value matches matcher.""" + command_class = None + if "commandClass" in value_data: + command_class = CommandClass(value_data["commandClass"]) + zwave_value_id = ZwaveValueMatcher( + property_=value_data.get("property"), + command_class=command_class, + endpoint=value_data.get("endpoint"), + property_key=value_data.get("propertyKey"), + ) + return all( + redacted_field_val is None or redacted_field_val == zwave_value_field_val + for redacted_field_val, zwave_value_field_val in zip( + astuple(matcher), astuple(zwave_value_id) + ) + ) + + @callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """ diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5b085ab0bb375e..38c1e8b181ff09 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -21,5 +21,6 @@ } ], "zeroconf": ["_zwave-js-server._tcp.local."], - "loggers": ["zwave_js_server"] + "loggers": ["zwave_js_server"], + "integration_type": "hub" } diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index f898170e3082cf..7f7f5d65bb83ab 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -113,10 +113,7 @@ def __init__( super().__init__(config_entry, driver, info) max_value = cast(int, self.info.primary_value.metadata.max) min_value = cast(int, self.info.primary_value.metadata.min) - self.correction_factor = max_value - min_value - # Fallback in case we can't properly calculate correction factor - if self.correction_factor == 0: - self.correction_factor = 1 + self.correction_factor = (max_value - min_value) or 1 # Entity class attributes self._attr_native_min_value = 0 diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 0360b96817348a..adb5820657f2a1 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -11,7 +11,6 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -173,7 +172,6 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") + assert self._target_value is not None key = next(key for key, val in self._lookup_map.items() if val == option) - await self.info.node.async_set_value(target_value, int(key)) + await self.info.node.async_set_value(self._target_value, int(key)) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 63a9071ffb6da2..2dfeaaa4a8d601 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -88,13 +88,14 @@ def raise_exceptions_from_results( if errors := [ tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) ]: - lines = ( - f"{len(errors)} error(s):", + lines = [ *( f"{zwave_object} - {error.__class__.__name__}: {error.args[0]}" for zwave_object, error in errors - ), - ) + ) + ] + if len(lines) > 1: + lines.insert(0, f"{len(errors)} error(s):") raise HomeAssistantError("\n".join(lines)) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 687d486888cedf..de9d4842ff7dbb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -98,10 +98,12 @@ refresh_value: description: Force update value(s) for a Z-Wave entity fields: entity_id: - name: Entity - description: Entity whose value(s) should be refreshed + name: Entities + description: Entities to refresh values for. required: true - example: sensor.family_room_motion + example: | + - sensor.family_room_motion + - switch.kitchen selector: entity: integration: zwave_js diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 19587cf0c0fbac..7446edb0c5d99d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -58,7 +58,8 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "not_zwave_device": "Discovered device is not a Z-Wave device." + "not_zwave_device": "Discovered device is not a Z-Wave device.", + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." }, "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", diff --git a/homeassistant/components/zwave_js/translations/bg.json b/homeassistant/components/zwave_js/translations/bg.json index 7bf8bfcc76474c..0ed3ce16d2f1c2 100644 --- a/homeassistant/components/zwave_js/translations/bg.json +++ b/homeassistant/components/zwave_js/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "not_zwave_js_addon": "\u041e\u0442\u043a\u0440\u0438\u0442\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u043d\u0435 \u0435 \u043e\u0444\u0438\u0446\u0438\u0430\u043b\u043d\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u043d\u0430 Z-Wave JS." + }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index 455fd8f9127f09..fb478f3ad5ee7b 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -10,7 +10,8 @@ "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "cannot_connect": "Ha fallat la connexi\u00f3", "discovery_requires_supervisor": "El descobriment requereix el supervisor.", - "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave." + "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave.", + "not_zwave_js_addon": "El complement descobert no \u00e9s el complement oficial Z-Wave JS." }, "error": { "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS. Comprova la configuraci\u00f3.", diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index e200e086444c5a..fb3c7e1a69d9f4 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -10,7 +10,8 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen", "discovery_requires_supervisor": "Discovery erfordert den Supervisor.", - "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t." + "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t.", + "not_zwave_js_addon": "Das entdeckte Add-on ist nicht das offizielle Z-Wave JS-Add-on." }, "error": { "addon_start_failed": "Fehler beim Starten des Z-Wave JS Add-Ons. \u00dcberpr\u00fcfe die Konfiguration.", diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 9224e27d90bec4..3d288c3ebae967 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -10,7 +10,8 @@ "already_in_progress": "Configuration flow is already in progress", "cannot_connect": "Failed to connect", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "not_zwave_device": "Discovered device is not a Z-Wave device." + "not_zwave_device": "Discovered device is not a Z-Wave device.", + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." }, "error": { "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index ff4d48f3f211c3..73da28f6d1e1c0 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -10,7 +10,8 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "cannot_connect": "No se pudo conectar", "discovery_requires_supervisor": "El descubrimiento requiere del supervisor.", - "not_zwave_device": "El dispositivo descubierto no es un dispositivo Z-Wave." + "not_zwave_device": "El dispositivo descubierto no es un dispositivo Z-Wave.", + "not_zwave_js_addon": "El complemento descubierto no es el complemento oficial de Z-Wave JS." }, "error": { "addon_start_failed": "No se pudo iniciar el complemento Z-Wave JS. Comprueba la configuraci\u00f3n.", diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index ea0686e424f980..df15c31b8c23d7 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -10,7 +10,8 @@ "already_in_progress": "Seadistamine on juba k\u00e4imas", "cannot_connect": "\u00dchendamine nurjus", "discovery_requires_supervisor": "Avastamine n\u00f5uab supervisorit.", - "not_zwave_device": "Avastatud seade ei ole Z-Wave seade." + "not_zwave_device": "Avastatud seade ei ole Z-Wave seade.", + "not_zwave_js_addon": "Avastatud lisandmoodul ei ole ametlik Z-Wave JS-i lisandmoodul." }, "error": { "addon_start_failed": "Z-Wave JS lisandmooduli k\u00e4ivitamine nurjus. Kontrolli seadistusi.", @@ -91,6 +92,12 @@ "zwave_js.value_updated.value": "Z-Wave JS v\u00e4\u00e4rtuse muutus" } }, + "issues": { + "invalid_server_version": { + "description": "Z-Wave JS Serveri versioon, mida praegu kasutad, on selle Home Assistanti versiooni jaoks liiga vana. Selle probleemi lahendamiseks v\u00e4rskenda Z-Wave JS Server uusimale versioonile.", + "title": "Vajalik on Z-Wave JS Serveri uuem versioon" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Z-Wave JS lisandmooduli tuvastusteabe hankimine nurjus.", diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index cf7552491c732d..55c613e740ac47 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -10,7 +10,8 @@ "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "discovery_requires_supervisor": "La d\u00e9couverte n\u00e9cessite le superviseur.", - "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave." + "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave.", + "not_zwave_js_addon": "Le module compl\u00e9mentaire d\u00e9couvert n'est pas le module compl\u00e9mentaire officiel de Z-Wave JS." }, "error": { "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 5bf50719b49686..b1e65cc05e2b5d 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -10,7 +10,8 @@ "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "discovery_requires_supervisor": "A felfedez\u00e9shez a fel\u00fcgyel\u0151re van sz\u00fcks\u00e9g.", - "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z." + "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z.", + "not_zwave_js_addon": "A felfedezett b\u0151v\u00edtm\u00e9ny nem a hivatalos Z-Wave JS b\u0151v\u00edtm\u00e9ny." }, "error": { "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t.", diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 1aa3c5258f501e..c8fe3f87f669d8 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -10,7 +10,8 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "cannot_connect": "Gagal terhubung", "discovery_requires_supervisor": "Fitur penemuan membutuhkan supervisor.", - "not_zwave_device": "Perangkat yang ditemukan bukan perangkat Z-Wave." + "not_zwave_device": "Perangkat yang ditemukan bukan perangkat Z-Wave.", + "not_zwave_js_addon": "Add-on yang ditemukan bukanlah add-on Z-Wave JS resmi." }, "error": { "addon_start_failed": "Gagal memulai add-on Z-Wave JS. Periksa konfigurasi.", diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 34977cdd48c6ee..d104f510eaf747 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -10,7 +10,8 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "cannot_connect": "Impossibile connettersi", "discovery_requires_supervisor": "Il rilevamento richiede il Supervisor.", - "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave." + "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave.", + "not_zwave_js_addon": "Il componente aggiuntivo rilevato non \u00e8 il componente aggiuntivo Z-Wave JS ufficiale." }, "error": { "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS. Controlla la configurazione.", diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 41568815193fe6..c42fff1813906e 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -10,7 +10,8 @@ "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", "discovery_requires_supervisor": "\u691c\u51fa\u306b\u306fSupervisor\u304c\u5fc5\u8981\u3067\u3059\u3002", - "not_zwave_device": "\u767a\u898b\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001Z-Wave\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + "not_zwave_device": "\u767a\u898b\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001Z-Wave\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "not_zwave_js_addon": "\u767a\u898b\u3055\u308c\u305f\u30a2\u30c9\u30aa\u30f3\u306f\u3001Z-Wave JS\u306e\u516c\u5f0f\u30a2\u30c9\u30aa\u30f3\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" }, "error": { "addon_start_failed": "Z-Wave JS \u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", diff --git a/homeassistant/components/zwave_js/translations/nb.json b/homeassistant/components/zwave_js/translations/nb.json new file mode 100644 index 00000000000000..42a62fb5164006 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/nb.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + }, + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index f87b7a701e30b6..f57791b23338c0 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -91,6 +91,11 @@ "zwave_js.value_updated.value": "Waardeverandering op een Z-Wave JS-waarde" } }, + "issues": { + "invalid_server_version": { + "title": "Nieuwere versie van Z-Wave JS-server vereist" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Ophalen van ontdekkingsinformatie voor Z-Wave JS-add-on is mislukt.", diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 6e9ae85cd74764..7c3cec3f6f997c 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -10,7 +10,8 @@ "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes", "discovery_requires_supervisor": "Oppdagelsen krever veilederen.", - "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet." + "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet.", + "not_zwave_js_addon": "Oppdaget tillegg er ikke det offisielle Z-Wave JS tillegget." }, "error": { "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegg. Sjekk konfigurasjonen.", diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index a4d5519491b3d3..2028a8a122980f 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -10,7 +10,8 @@ "already_in_progress": "Konfiguracja jest ju\u017c w toku", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "discovery_requires_supervisor": "Wykrywanie wymaga Supervisora.", - "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave." + "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave.", + "not_zwave_js_addon": "Wykryty dodatek nie jest oficjalnym dodatkiem Z-Wave JS." }, "error": { "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS. Sprawd\u017a konfiguracj\u0119", diff --git a/homeassistant/components/zwave_js/translations/pt-BR.json b/homeassistant/components/zwave_js/translations/pt-BR.json index 83b6bed636573e..dd26153495cf83 100644 --- a/homeassistant/components/zwave_js/translations/pt-BR.json +++ b/homeassistant/components/zwave_js/translations/pt-BR.json @@ -10,7 +10,8 @@ "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "cannot_connect": "Falha ao conectar", "discovery_requires_supervisor": "A descoberta requer o supervisor.", - "not_zwave_device": "O dispositivo descoberto n\u00e3o \u00e9 um dispositivo Z-Wave." + "not_zwave_device": "O dispositivo descoberto n\u00e3o \u00e9 um dispositivo Z-Wave.", + "not_zwave_js_addon": "O complemento descoberto n\u00e3o \u00e9 o complemento oficial do Z-Wave JS." }, "error": { "addon_start_failed": "Falha ao iniciar o add-on Z-Wave JS. Verifique a configura\u00e7\u00e3o.", diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index bbf816046dfdbd..3ffe43abb6fc45 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -10,7 +10,8 @@ "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "discovery_requires_supervisor": "\u0414\u043b\u044f \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f Supervisor.", - "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave." + "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave.", + "not_zwave_js_addon": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043d\u0435\u043e\u0444\u0438\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0435 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS." }, "error": { "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", diff --git a/homeassistant/components/zwave_js/translations/sv.json b/homeassistant/components/zwave_js/translations/sv.json index b619c54026b41f..448069933d95e8 100644 --- a/homeassistant/components/zwave_js/translations/sv.json +++ b/homeassistant/components/zwave_js/translations/sv.json @@ -91,6 +91,12 @@ "zwave_js.value_updated.value": "V\u00e4rdef\u00f6r\u00e4ndring p\u00e5 ett Z-Wave JS-v\u00e4rde" } }, + "issues": { + "invalid_server_version": { + "description": "Den version av Z-Wave JS Server du f\u00f6r n\u00e4rvarande k\u00f6r \u00e4r f\u00f6r gammal f\u00f6r den h\u00e4r versionen av Home Assistant. Uppdatera Z-Wave JS Server till den senaste versionen f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Nyare version av Z-Wave JS Server beh\u00f6vs" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Det gick inte att h\u00e4mta Z-Wave JS-till\u00e4ggsuppt\u00e4cktsinformation.", diff --git a/homeassistant/components/zwave_js/translations/tr.json b/homeassistant/components/zwave_js/translations/tr.json index 21d8f03bec60d6..ed45816c2dd9b9 100644 --- a/homeassistant/components/zwave_js/translations/tr.json +++ b/homeassistant/components/zwave_js/translations/tr.json @@ -10,7 +10,8 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "cannot_connect": "Ba\u011flanma hatas\u0131", "discovery_requires_supervisor": "Tarama, s\u00fcperviz\u00f6r\u00fc gerektirir.", - "not_zwave_device": "Bulunan cihaz bir Z-Wave cihaz\u0131 de\u011fil." + "not_zwave_device": "Bulunan cihaz bir Z-Wave cihaz\u0131 de\u011fil.", + "not_zwave_js_addon": "Ke\u015ffedilen eklenti, resmi Z-Wave JS eklentisi de\u011fildir." }, "error": { "addon_start_failed": "Z-Wave JS eklentisi ba\u015flat\u0131lamad\u0131. Yap\u0131land\u0131rmay\u0131 kontrol edin.", @@ -91,6 +92,12 @@ "zwave_js.value_updated.value": "Z-Wave JS De\u011ferinde de\u011fer de\u011fi\u015fikli\u011fi" } }, + "issues": { + "invalid_server_version": { + "description": "\u015eu anda \u00e7al\u0131\u015ft\u0131rd\u0131\u011f\u0131n\u0131z Z-Wave JS Server s\u00fcr\u00fcm\u00fc, Home Assistant'\u0131n bu s\u00fcr\u00fcm\u00fc i\u00e7in \u00e7ok eski. Bu sorunu gidermek i\u00e7in l\u00fctfen Z-Wave JS Sunucusunu en son s\u00fcr\u00fcme g\u00fcncelleyin.", + "title": "Z-Wave JS Sunucusunun daha yeni s\u00fcr\u00fcm\u00fc gerekli" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Z-Wave JS eklenti ke\u015fif bilgileri al\u0131namad\u0131.", diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 3c5f898324a452..c970bae125edbc 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -10,7 +10,8 @@ "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "cannot_connect": "\u9023\u7dda\u5931\u6557", "discovery_requires_supervisor": "\u641c\u7d22\u529f\u80fd\u9700\u8981 Supervisor \u6b0a\u9650\u3002", - "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e" + "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e", + "not_zwave_js_addon": "\u767c\u73fe\u4e4b\u9644\u52a0\u5143\u4ef6\u4e26\u975e\u5b98\u65b9 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u3002" }, "error": { "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u52d5\u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u8a2d\u5b9a\u3002", diff --git a/homeassistant/config.py b/homeassistant/config.py index 91f94bbbf40a86..e56dff4e491696 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -44,9 +44,7 @@ CONF_TIME_ZONE, CONF_TYPE, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, - TEMP_CELSIUS, __version__, ) from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback @@ -61,7 +59,7 @@ from .loader import Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env -from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from .util.unit_system import get_unit_system, validate_unit_system from .util.yaml import SECRET_YAML, Secrets, load_yaml _LOGGER = logging.getLogger(__name__) @@ -89,6 +87,10 @@ # Loads default set of integrations. Do not remove. default_config: +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + # Text to speech tts: - platform: google_translate @@ -204,8 +206,8 @@ def _filter_bad_internal_external_urls(conf: dict) -> dict: CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, CONF_ELEVATION: vol.Coerce(int), - vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: cv.unit_system, + vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + CONF_UNIT_SYSTEM: validate_unit_system, CONF_TIME_ZONE: cv.time_zone, vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, @@ -304,26 +306,26 @@ def _write_default_config(config_dir: str) -> bool: # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: - with open(config_path, "wt", encoding="utf8") as config_file: + with open(config_path, "w", encoding="utf8") as config_file: config_file.write(DEFAULT_CONFIG) if not os.path.isfile(secret_path): - with open(secret_path, "wt", encoding="utf8") as secret_file: + with open(secret_path, "w", encoding="utf8") as secret_file: secret_file.write(DEFAULT_SECRETS) - with open(version_path, "wt", encoding="utf8") as version_file: + with open(version_path, "w", encoding="utf8") as version_file: version_file.write(__version__) if not os.path.isfile(automation_yaml_path): - with open(automation_yaml_path, "wt", encoding="utf8") as automation_file: + with open(automation_yaml_path, "w", encoding="utf8") as automation_file: automation_file.write("[]") if not os.path.isfile(script_yaml_path): - with open(script_yaml_path, "wt", encoding="utf8"): + with open(script_yaml_path, "w", encoding="utf8"): pass if not os.path.isfile(scene_yaml_path): - with open(scene_yaml_path, "wt", encoding="utf8"): + with open(scene_yaml_path, "w", encoding="utf8"): pass return True @@ -421,7 +423,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Migrating google tts to google_translate tts") config_raw = config_raw.replace(TTS_PRE_92, TTS_92) try: - with open(config_path, "wt", encoding="utf-8") as config_file: + with open(config_path, "w", encoding="utf-8") as config_file: config_file.write(config_raw) except OSError: _LOGGER.exception("Migrating to google_translate tts failed") @@ -433,7 +435,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: if os.path.isdir(lib_path): shutil.rmtree(lib_path) - with open(version_path, "wt", encoding="utf8") as outp: + with open(version_path, "w", encoding="utf8") as outp: outp.write(__version__) @@ -603,22 +605,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non hass.data[DATA_CUSTOMIZE] = EntityValues(cust_exact, cust_domain, cust_glob) if CONF_UNIT_SYSTEM in config: - if config[CONF_UNIT_SYSTEM] == CONF_UNIT_SYSTEM_IMPERIAL: - hac.units = IMPERIAL_SYSTEM - else: - hac.units = METRIC_SYSTEM - elif CONF_TEMPERATURE_UNIT in config: - unit = config[CONF_TEMPERATURE_UNIT] - hac.units = METRIC_SYSTEM if unit == TEMP_CELSIUS else IMPERIAL_SYSTEM - _LOGGER.warning( - "Found deprecated temperature unit in core " - "configuration expected unit system. Replace '%s: %s' " - "with '%s: %s'", - CONF_TEMPERATURE_UNIT, - unit, - CONF_UNIT_SYSTEM, - hac.units.name, - ) + hac.units = get_unit_system(config[CONF_UNIT_SYSTEM]) def _log_pkg_error(package: str, component: str, config: dict, message: str) -> None: diff --git a/homeassistant/const.py b/homeassistant/const.py index da1e691903fe11..5ba07ebf8fdb76 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,8 +7,8 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2022 -MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "5" +MINOR_VERSION: Final = 11 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) @@ -396,7 +396,9 @@ class Platform(StrEnum): ATTR_UNIT_OF_MEASUREMENT: Final = "unit_of_measurement" CONF_UNIT_SYSTEM_METRIC: Final = "metric" +"""Deprecated: please use a local constant.""" CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial" +"""Deprecated: please use a local constant.""" # Electrical attributes ATTR_VOLTAGE: Final = "voltage" @@ -476,18 +478,43 @@ class Platform(StrEnum): # Apparent power units POWER_VOLT_AMPERE: Final = "VA" + # Power units +class UnitOfPower(StrEnum): + """Power units.""" + + WATT = "W" + KILO_WATT = "kW" + BTU_PER_HOUR = "BTU/h" + + POWER_WATT: Final = "W" +"""Deprecated: please use UnitOfPower.WATT.""" POWER_KILO_WATT: Final = "kW" +"""Deprecated: please use UnitOfPower.KILO_WATT.""" POWER_BTU_PER_HOUR: Final = "BTU/h" +"""Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" # Reactive power units POWER_VOLT_AMPERE_REACTIVE: Final = "var" + # Energy units -ENERGY_WATT_HOUR: Final = "Wh" +class UnitOfEnergy(StrEnum): + """Energy units.""" + + GIGA_JOULE = "GJ" + KILO_WATT_HOUR = "kWh" + MEGA_WATT_HOUR = "MWh" + WATT_HOUR = "Wh" + + ENERGY_KILO_WATT_HOUR: Final = "kWh" +"""Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" ENERGY_MEGA_WATT_HOUR: Final = "MWh" +"""Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" +ENERGY_WATT_HOUR: Final = "Wh" +"""Deprecated: please use UnitOfEnergy.WATT_HOUR.""" # Electric_current units ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" @@ -505,10 +532,22 @@ class Platform(StrEnum): CURRENCY_DOLLAR: Final = "$" CURRENCY_CENT: Final = "¢" + # Temperature units +class UnitOfTemperature(StrEnum): + """Temperature units.""" + + CELSIUS = "°C" + FAHRENHEIT = "°F" + KELVIN = "K" + + TEMP_CELSIUS: Final = "°C" +"""Deprecated: please use UnitOfTemperature.CELSIUS""" TEMP_FAHRENHEIT: Final = "°F" +"""Deprecated: please use UnitOfTemperature.FAHRENHEIT""" TEMP_KELVIN: Final = "K" +"""Deprecated: please use UnitOfTemperature.KELVIN""" # Time units TIME_MICROSECONDS: Final = "μs" @@ -521,16 +560,37 @@ class Platform(StrEnum): TIME_MONTHS: Final = "m" TIME_YEARS: Final = "y" + # Length units +class UnitOfLength(StrEnum): + """Length units.""" + + MILLIMETERS = "mm" + CENTIMETERS = "cm" + METERS = "m" + KILOMETERS = "km" + INCHES = "in" + FEET = "ft" + YARDS = "yd" + MILES = "mi" + + LENGTH_MILLIMETERS: Final = "mm" +"""Deprecated: please use UnitOfLength.MILLIMETERS.""" LENGTH_CENTIMETERS: Final = "cm" +"""Deprecated: please use UnitOfLength.CENTIMETERS.""" LENGTH_METERS: Final = "m" +"""Deprecated: please use UnitOfLength.METERS.""" LENGTH_KILOMETERS: Final = "km" - +"""Deprecated: please use UnitOfLength.KILOMETERS.""" LENGTH_INCHES: Final = "in" +"""Deprecated: please use UnitOfLength.INCHES.""" LENGTH_FEET: Final = "ft" +"""Deprecated: please use UnitOfLength.FEET.""" LENGTH_YARD: Final = "yd" +"""Deprecated: please use UnitOfLength.YARDS.""" LENGTH_MILES: Final = "mi" +"""Deprecated: please use UnitOfLength.MILES.""" # Frequency units FREQUENCY_HERTZ: Final = "Hz" @@ -538,32 +598,77 @@ class Platform(StrEnum): FREQUENCY_MEGAHERTZ: Final = "MHz" FREQUENCY_GIGAHERTZ: Final = "GHz" + # Pressure units +class UnitOfPressure(StrEnum): + """Pressure units.""" + + PA = "Pa" + HPA = "hPa" + KPA = "kPa" + BAR = "bar" + CBAR = "cbar" + MBAR = "mbar" + MMHG = "mmHg" + INHG = "inHg" + PSI = "psi" + + PRESSURE_PA: Final = "Pa" +"""Deprecated: please use UnitOfPressure.PA""" PRESSURE_HPA: Final = "hPa" +"""Deprecated: please use UnitOfPressure.HPA""" PRESSURE_KPA: Final = "kPa" +"""Deprecated: please use UnitOfPressure.KPA""" PRESSURE_BAR: Final = "bar" +"""Deprecated: please use UnitOfPressure.BAR""" PRESSURE_CBAR: Final = "cbar" +"""Deprecated: please use UnitOfPressure.CBAR""" PRESSURE_MBAR: Final = "mbar" +"""Deprecated: please use UnitOfPressure.MBAR""" PRESSURE_MMHG: Final = "mmHg" +"""Deprecated: please use UnitOfPressure.MMHG""" PRESSURE_INHG: Final = "inHg" +"""Deprecated: please use UnitOfPressure.INHG""" PRESSURE_PSI: Final = "psi" +"""Deprecated: please use UnitOfPressure.PSI""" # Sound pressure units SOUND_PRESSURE_DB: Final = "dB" SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" + # Volume units +class UnitOfVolume(StrEnum): + """Volume units.""" + + CUBIC_FEET = "ft³" + CUBIC_METERS = "m³" + LITERS = "L" + MILLILITERS = "mL" + GALLONS = "gal" + """Assumed to be US gallons in conversion utilities. + + British/Imperial gallons are not yet supported""" + FLUID_OUNCES = "fl. oz." + """Assumed to be US fluid ounces in conversion utilities. + + British/Imperial fluid ounces are not yet supported""" + + VOLUME_LITERS: Final = "L" +"""Deprecated: please use UnitOfVolume.LITERS""" VOLUME_MILLILITERS: Final = "mL" +"""Deprecated: please use UnitOfVolume.MILLILITERS""" VOLUME_CUBIC_METERS: Final = "m³" +"""Deprecated: please use UnitOfVolume.CUBIC_METERS""" VOLUME_CUBIC_FEET: Final = "ft³" +"""Deprecated: please use UnitOfVolume.CUBIC_FEET""" VOLUME_GALLONS: Final = "gal" -"""US gallon (British gallon is not yet supported)""" - +"""Deprecated: please use UnitOfVolume.GALLONS""" VOLUME_FLUID_OUNCE: Final = "fl. oz." -"""US fluid ounce (British fluid ounce is not yet supported)""" +"""Deprecated: please use UnitOfVolume.FLUID_OUNCES""" # Volume Flow Rate units VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = "m³/h" @@ -572,14 +677,31 @@ class Platform(StrEnum): # Area units AREA_SQUARE_METERS: Final = "m²" + # Mass units +class UnitOfMass(StrEnum): + """Mass units.""" + + GRAMS = "g" + KILOGRAMS = "kg" + MILLIGRAMS = "mg" + MICROGRAMS = "µg" + OUNCES = "oz" + POUNDS = "lb" + + MASS_GRAMS: Final = "g" +"""Deprecated: please use UnitOfMass.GRAMS""" MASS_KILOGRAMS: Final = "kg" +"""Deprecated: please use UnitOfMass.KILOGRAMS""" MASS_MILLIGRAMS: Final = "mg" +"""Deprecated: please use UnitOfMass.MILLIGRAMS""" MASS_MICROGRAMS: Final = "µg" - +"""Deprecated: please use UnitOfMass.MICROGRAMS""" MASS_OUNCES: Final = "oz" +"""Deprecated: please use UnitOfMass.OUNCES""" MASS_POUNDS: Final = "lb" +"""Deprecated: please use UnitOfMass.POUNDS""" # Conductivity units CONDUCTIVITY: Final = "µS/cm" @@ -600,10 +722,38 @@ class Platform(StrEnum): IRRADIATION_WATTS_PER_SQUARE_METER: Final = "W/m²" IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" + +class UnitOfVolumetricFlux(StrEnum): + """Volumetric flux, commonly used for precipitation intensity. + + The derivation of these units is a volume of rain amassing in a container + with constant cross section in a given time + """ + + INCHES_PER_DAY = "in/d" + """Derived from in³/(in².d)""" + + INCHES_PER_HOUR = "in/h" + """Derived from in³/(in².h)""" + + MILLIMETERS_PER_DAY = "mm/d" + """Derived from mm³/(mm².d)""" + + MILLIMETERS_PER_HOUR = "mm/h" + """Derived from mm³/(mm².h)""" + + # Precipitation units -PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +# The derivation of these units is a volume of rain amassing in a container +# with constant cross section PRECIPITATION_INCHES: Final = "in" +PRECIPITATION_MILLIMETERS: Final = "mm" + +PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" + PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" @@ -613,15 +763,38 @@ class Platform(StrEnum): CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" + # Speed units -SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +class UnitOfSpeed(StrEnum): + """Speed units.""" + + FEET_PER_SECOND = "ft/s" + METERS_PER_SECOND = "m/s" + KILOMETERS_PER_HOUR = "km/h" + KNOTS = "kn" + MILES_PER_HOUR = "mph" + + SPEED_FEET_PER_SECOND: Final = "ft/s" -SPEED_INCHES_PER_DAY: Final = "in/d" +"""Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" SPEED_METERS_PER_SECOND: Final = "m/s" -SPEED_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +"""Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" SPEED_KNOTS: Final = "kn" +"""Deprecated: please use UnitOfSpeed.KNOTS""" SPEED_MILES_PER_HOUR: Final = "mph" +"""Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" + +SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" + +SPEED_INCHES_PER_DAY: Final = "in/d" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" + +SPEED_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" + # Signal_strength units SIGNAL_STRENGTH_DECIBELS: Final = "dB" @@ -786,4 +959,4 @@ class Platform(StrEnum): # User used by Supervisor HASSIO_USER_NAME = "Supervisor" -SIGNAL_BOOTSTRAP_INTEGRATONS = "bootstrap_integrations" +SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" diff --git a/homeassistant/core.py b/homeassistant/core.py index 01c75fb707e34b..8f9287aedac097 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -49,7 +49,6 @@ ATTR_FRIENDLY_NAME, ATTR_SERVICE, ATTR_SERVICE_DATA, - CONF_UNIT_SYSTEM_IMPERIAL, EVENT_CALL_SERVICE, EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_CLOSE, @@ -82,7 +81,13 @@ ) from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager -from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from .util.unit_system import ( + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + METRIC_SYSTEM, + UnitSystem, + get_unit_system, +) # Typing imports that create a circular dependency if TYPE_CHECKING: @@ -108,6 +113,7 @@ CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 +CORE_STORAGE_MINOR_VERSION = 2 DOMAIN = "homeassistant" @@ -649,7 +655,7 @@ async def async_block_till_done(self) -> None: else: await asyncio.sleep(0) - async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: + async def _await_and_log_pending(self, pending: Collection[Awaitable[Any]]) -> None: """Await and log tasks that take a long time.""" wait_time = 0 while pending: @@ -1790,6 +1796,8 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize a new config object.""" self.hass = hass + self._store = self._ConfigStore(self.hass) + self.latitude: float = 0 self.longitude: float = 0 self.elevation: int = 0 @@ -1940,9 +1948,9 @@ def _update( if elevation is not None: self.elevation = elevation if unit_system is not None: - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: - self.units = IMPERIAL_SYSTEM - else: + try: + self.units = get_unit_system(unit_system) + except ValueError: self.units = METRIC_SYSTEM if location_name is not None: self.location_name = location_name @@ -1958,24 +1966,12 @@ def _update( async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" self._update(source=ConfigSource.STORAGE, **kwargs) - await self.async_store() + await self._async_store() self.hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, kwargs) async def async_load(self) -> None: """Load [homeassistant] core config.""" - # Circular dep - # pylint: disable=import-outside-toplevel - from .helpers.storage import Store - - store = Store[dict[str, Any]]( - self.hass, - CORE_STORAGE_VERSION, - CORE_STORAGE_KEY, - private=True, - atomic_writes=True, - ) - - if not (data := await store.async_load()): + if not (data := await self._store.async_load()): return # In 2021.9 we fixed validation to disallow a path (because that's never correct) @@ -1997,7 +1993,7 @@ async def async_load(self) -> None: latitude=data.get("latitude"), longitude=data.get("longitude"), elevation=data.get("elevation"), - unit_system=data.get("unit_system"), + unit_system=data.get("unit_system_v2"), location_name=data.get("location_name"), time_zone=data.get("time_zone"), external_url=data.get("external_url", _UNDEF), @@ -2005,17 +2001,15 @@ async def async_load(self) -> None: currency=data.get("currency"), ) - async def async_store(self) -> None: + async def _async_store(self) -> None: """Store [homeassistant] core config.""" - # Circular dep - # pylint: disable=import-outside-toplevel - from .helpers.storage import Store - data = { "latitude": self.latitude, "longitude": self.longitude, "elevation": self.elevation, - "unit_system": self.units.name, + # We don't want any integrations to use the name of the unit system + # so we are using the private attribute here + "unit_system_v2": self.units._name, # pylint: disable=protected-access "location_name": self.location_name, "time_zone": self.time_zone, "external_url": self.external_url, @@ -2023,11 +2017,47 @@ async def async_store(self) -> None: "currency": self.currency, } - store: Store[dict[str, Any]] = Store( - self.hass, - CORE_STORAGE_VERSION, - CORE_STORAGE_KEY, - private=True, - atomic_writes=True, - ) - await store.async_save(data) + await self._store.async_save(data) + + # Circular dependency prevents us from generating the class at top level + # pylint: disable-next=import-outside-toplevel + from .helpers.storage import Store + + class _ConfigStore(Store[dict[str, Any]]): + """Class to help storing Config data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + CORE_STORAGE_VERSION, + CORE_STORAGE_KEY, + private=True, + atomic_writes=True, + minor_version=CORE_STORAGE_MINOR_VERSION, + ) + self._original_unit_system: str | None = None # from old store 1.1 + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1 and old_minor_version < 2: + # In 1.2, we remove support for "imperial", replaced by "us_customary" + # Using a new key to allow rollback + self._original_unit_system = data.get("unit_system") + data["unit_system_v2"] = self._original_unit_system + if data["unit_system_v2"] == _CONF_UNIT_SYSTEM_IMPERIAL: + data["unit_system_v2"] = _CONF_UNIT_SYSTEM_US_CUSTOMARY + if old_major_version > 1: + raise NotImplementedError + return data + + async def async_save(self, data: dict[str, Any]) -> None: + if self._original_unit_system: + data["unit_system"] = self._original_unit_system + return await super().async_save(data) diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 181a4034bf0d7d..c4dd22cef17846 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -5,6 +5,10 @@ from __future__ import annotations BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ + { + "domain": "airthings_ble", + "manufacturer_id": 820, + }, { "domain": "bluemaestro", "manufacturer_id": 307, @@ -223,6 +227,10 @@ "local_name": "Moat_S*", "connectable": False, }, + { + "domain": "oralb", + "manufacturer_id": 220, + }, { "domain": "qingping", "local_name": "Qingping*", @@ -265,6 +273,14 @@ "local_name": "SensorPush*", "connectable": False, }, + { + "domain": "snooz", + "local_name": "Snooz*", + }, + { + "domain": "snooz", + "service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0", + }, { "domain": "switchbot", "service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ba6c76d329afe8..772068401a5453 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ "airly", "airnow", "airthings", + "airthings_ble", "airtouch4", "airvisual", "airzone", @@ -279,6 +280,7 @@ "opentherm_gw", "openuv", "openweathermap", + "oralb", "overkiz", "ovo_energy", "owntracks", @@ -355,6 +357,7 @@ "smarttub", "smhi", "sms", + "snooz", "solaredge", "solarlog", "solax", @@ -458,6 +461,7 @@ "yeelight", "yolink", "youless", + "zamg", "zerproc", "zha", "zwave_js", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index ae1b7a76a88c87..4b8dee0d956ded 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -93,6 +93,7 @@ {"domain": "roomba", "hostname": "irobot-*", "macaddress": "501479*"}, {"domain": "roomba", "hostname": "roomba-*", "macaddress": "80A589*"}, {"domain": "roomba", "hostname": "roomba-*", "macaddress": "DCF505*"}, + {"domain": "roomba", "hostname": "roomba-*", "macaddress": "204EF6*"}, {"domain": "samsungtv", "registered_devices": True}, {"domain": "samsungtv", "hostname": "tizen*"}, {"domain": "samsungtv", "macaddress": "4844F7*"}, @@ -142,6 +143,7 @@ {"domain": "tplink", "registered_devices": True}, {"domain": "tplink", "hostname": "es*", "macaddress": "54AF97*"}, {"domain": "tplink", "hostname": "ep*", "macaddress": "E848B8*"}, + {"domain": "tplink", "hostname": "ep*", "macaddress": "1C61B4*"}, {"domain": "tplink", "hostname": "ep*", "macaddress": "003192*"}, {"domain": "tplink", "hostname": "hs*", "macaddress": "1C3BF3*"}, {"domain": "tplink", "hostname": "hs*", "macaddress": "50C7BF*"}, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4fd58cd88f34fa..08317d06a5c82b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1,752 +1,941 @@ { "integration": { + "3_day_blinds": { + "name": "3 Day Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "abode": { + "name": "Abode", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Abode" + "iot_class": "cloud_push" }, "accuweather": { + "name": "AccuWeather", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AccuWeather" + "iot_class": "cloud_polling" }, "acer_projector": { + "name": "Acer Projector", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Acer Projector" + "iot_class": "local_polling" }, "acmeda": { + "name": "Rollease Acmeda Automate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Rollease Acmeda Automate" + "iot_class": "local_push" }, "actiontec": { + "name": "Actiontec", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Actiontec" + "iot_class": "local_polling" }, "adax": { + "name": "Adax", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Adax" + "iot_class": "local_polling" }, "adguard": { + "name": "AdGuard Home", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "AdGuard Home" + "iot_class": "local_polling" }, "ads": { + "name": "ADS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "ADS" + "iot_class": "local_push" }, "advantage_air": { + "name": "Advantage Air", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Advantage Air" + "iot_class": "local_polling" }, "aemet": { + "name": "AEMET OpenData", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AEMET OpenData" + "iot_class": "cloud_polling" }, "aftership": { + "name": "AfterShip", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "AfterShip" + "iot_class": "cloud_polling" }, "agent_dvr": { + "name": "Agent DVR", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Agent DVR" + "iot_class": "local_polling" }, "airly": { + "name": "Airly", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Airly" + "iot_class": "cloud_polling" }, "airnow": { + "name": "AirNow", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AirNow" + "iot_class": "cloud_polling" }, "airthings": { - "config_flow": true, - "iot_class": "cloud_polling", - "name": "Airthings" + "name": "Airthings", + "integrations": { + "airthings": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airthings" + }, + "airthings_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Airthings BLE" + } + } }, "airtouch4": { + "name": "AirTouch 4", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "AirTouch 4" + "iot_class": "local_polling" }, "airvisual": { + "name": "AirVisual", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AirVisual" + "iot_class": "cloud_polling" }, "airzone": { + "name": "Airzone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Airzone" + "iot_class": "local_polling" }, "aladdin_connect": { + "name": "Aladdin Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aladdin Connect" + "iot_class": "cloud_polling" }, "alarmdecoder": { + "name": "AlarmDecoder", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "AlarmDecoder" + "iot_class": "local_push" }, "alert": { + "name": "Alert", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Alert" + "iot_class": "local_push" }, "almond": { + "name": "Almond", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Almond" + "iot_class": "local_polling" }, "alpha_vantage": { + "name": "Alpha Vantage", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Alpha Vantage" + "iot_class": "cloud_polling" }, "amazon": { "name": "Amazon", "integrations": { "alexa": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Alexa" }, "amazon_polly": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Polly" }, "aws": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Web Services (AWS)" }, "route53": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "AWS Route53" } } }, "amberelectric": { + "name": "Amber Electric", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Amber Electric" + "iot_class": "cloud_polling" }, "ambiclimate": { + "name": "Ambiclimate", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ambiclimate" + "iot_class": "cloud_polling" }, "ambient_station": { + "name": "Ambient Weather Station", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Ambient Weather Station" + "iot_class": "cloud_push" }, "amcrest": { + "name": "Amcrest", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Amcrest" + "iot_class": "local_polling" + }, + "amp_motorization": { + "name": "AMP Motorization", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "ampio": { + "name": "Ampio Smart Smog System", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Ampio Smart Smog System" + "iot_class": "cloud_polling" }, "android_ip_webcam": { + "name": "Android IP Webcam", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Android IP Webcam" + "iot_class": "local_polling" }, "androidtv": { + "name": "Android TV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Android TV" + "iot_class": "local_polling" }, "anel_pwrctrl": { + "name": "Anel NET-PwrCtrl", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Anel NET-PwrCtrl" + "iot_class": "local_polling" }, "anthemav": { + "name": "Anthem A/V Receivers", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Anthem A/V Receivers" + "iot_class": "local_push" }, "apache_kafka": { + "name": "Apache Kafka", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Apache Kafka" + "iot_class": "local_push" }, "apcupsd": { + "name": "APC UPS Daemon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "APC UPS Daemon" + "iot_class": "local_polling" }, "apple": { "name": "Apple", "integrations": { "apple_tv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Apple TV" }, "homekit_controller": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "homekit": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "HomeKit" }, "ibeacon": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "iBeacon Tracker" }, "icloud": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Apple iCloud" }, "itunes": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Apple iTunes" } } }, "apprise": { + "name": "Apprise", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Apprise" + "iot_class": "cloud_push" }, "aprs": { + "name": "APRS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "APRS" + "iot_class": "cloud_push" }, "aqualogic": { + "name": "AquaLogic", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "AquaLogic" + "iot_class": "local_push" }, "aquostv": { + "name": "Sharp Aquos TV", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Sharp Aquos TV" + "iot_class": "local_polling" }, "arcam_fmj": { + "name": "Arcam FMJ Receivers", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Arcam FMJ Receivers" + "iot_class": "local_polling" }, "arest": { + "name": "aREST", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "aREST" + "iot_class": "local_polling" }, "arris_tg2492lg": { + "name": "Arris TG2492LG", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Arris TG2492LG" + "iot_class": "local_polling" }, "aruba": { "name": "Aruba", "integrations": { "aruba": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Aruba" }, "cppm_tracker": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Aruba ClearPass" } } }, "arwn": { + "name": "Ambient Radio Weather Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ambient Radio Weather Network" + "iot_class": "local_polling" }, "aseko_pool_live": { + "name": "Aseko Pool Live", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aseko Pool Live" + "iot_class": "cloud_polling" }, "asterisk": { "name": "Asterisk", "integrations": { "asterisk_cdr": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Asterisk Call Detail Records" }, "asterisk_mbox": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Asterisk Voicemail" } } }, "asuswrt": { + "name": "ASUSWRT", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "ASUSWRT" + "iot_class": "local_polling" }, "atag": { + "name": "Atag", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Atag" + "iot_class": "local_polling" }, "aten_pe": { + "name": "ATEN Rack PDU", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ATEN Rack PDU" + "iot_class": "local_polling" }, "atome": { + "name": "Atome Linky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Atome Linky" + "iot_class": "cloud_polling" }, "august": { "name": "August Home", "integrations": { "august": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "August" }, "yalexs_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" } } }, + "august_ble": { + "name": "August Bluetooth", + "integration_type": "virtual", + "supported_by": "yalexs_ble" + }, "aurora": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "aurora_abb_powerone": { + "name": "Aurora ABB PowerOne Solar PV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Aurora ABB PowerOne Solar PV" + "iot_class": "local_polling" }, "aussie_broadband": { + "name": "Aussie Broadband", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aussie Broadband" + "iot_class": "cloud_polling" }, "avion": { + "name": "Avi-on", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Avi-on" + "iot_class": "assumed_state" }, "awair": { + "name": "Awair", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Awair" + "iot_class": "local_polling" }, "axis": { + "name": "Axis", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "Axis" + "iot_class": "local_push" }, "baf": { + "name": "Big Ass Fans", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Big Ass Fans" + "iot_class": "local_push" }, "baidu": { + "name": "Baidu", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Baidu" + "iot_class": "cloud_push" }, "balboa": { + "name": "Balboa Spa Client", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Balboa Spa Client" + "iot_class": "local_push" }, "bayesian": { + "name": "Bayesian", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bayesian" + "iot_class": "local_polling" }, "bbox": { + "name": "Bbox", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bbox" + "iot_class": "local_polling" }, "beewi_smartclim": { + "name": "BeeWi SmartClim BLE sensor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BeeWi SmartClim BLE sensor" + "iot_class": "local_polling" }, "bitcoin": { + "name": "Bitcoin", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Bitcoin" + "iot_class": "cloud_polling" }, "bizkaibus": { + "name": "Bizkaibus", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Bizkaibus" + "iot_class": "cloud_polling" }, "blackbird": { + "name": "Monoprice Blackbird Matrix Switch", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Monoprice Blackbird Matrix Switch" + "iot_class": "local_polling" }, "blebox": { + "name": "BleBox devices", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "BleBox devices" + "iot_class": "local_polling" }, "blink": { + "name": "Blink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Blink" + "iot_class": "cloud_polling" }, "blinksticklight": { + "name": "BlinkStick", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BlinkStick" + "iot_class": "local_polling" + }, + "bliss_automation": { + "name": "Bliss Automation", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "bloc_blinds": { + "name": "Bloc Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "blockchain": { + "name": "Blockchain.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Blockchain.com" + "iot_class": "cloud_polling" }, "bloomsky": { + "name": "BloomSky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "BloomSky" + "iot_class": "cloud_polling" }, "bluemaestro": { + "name": "BlueMaestro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "BlueMaestro" + "iot_class": "local_push" }, "bluesound": { + "name": "Bluesound", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bluesound" + "iot_class": "local_polling" }, "bluetooth": { + "name": "Bluetooth", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bluetooth" + "iot_class": "local_push" }, "bluetooth_le_tracker": { + "name": "Bluetooth LE Tracker", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Bluetooth LE Tracker" + "iot_class": "local_push" }, "bluetooth_tracker": { + "name": "Bluetooth Tracker", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bluetooth Tracker" + "iot_class": "local_polling" }, "bmw_connected_drive": { + "name": "BMW Connected Drive", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "BMW Connected Drive" + "iot_class": "cloud_polling" }, "bond": { + "name": "Bond", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bond" + "iot_class": "local_push" }, "bosch_shc": { + "name": "Bosch SHC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bosch SHC" + "iot_class": "local_push" + }, + "brel_home": { + "name": "Brel Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "broadlink": { + "name": "Broadlink", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Broadlink" + "iot_class": "local_polling" }, "brother": { + "name": "Brother Printer", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Brother Printer" + "iot_class": "local_polling" }, "brottsplatskartan": { + "name": "Brottsplatskartan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Brottsplatskartan" + "iot_class": "cloud_polling" }, "browser": { + "name": "Browser", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Browser" + "iot_class": "local_push" }, "brunt": { + "name": "Brunt Blind Engine", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Brunt Blind Engine" + "iot_class": "cloud_polling" }, "bsblan": { + "name": "BSB-Lan", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "BSB-Lan" + "iot_class": "local_polling" + }, + "bswitch": { + "name": "BSwitch", + "integration_type": "virtual", + "supported_by": "switchbee" }, "bt_home_hub_5": { + "name": "BT Home Hub 5", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BT Home Hub 5" + "iot_class": "local_polling" }, "bt_smarthub": { + "name": "BT Smart Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BT Smart Hub" + "iot_class": "local_polling" }, "bthome": { + "name": "BTHome", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "BTHome" + "iot_class": "local_push" + }, + "bticino": { + "name": "BTicino", + "integration_type": "virtual", + "supported_by": "netatmo" + }, + "bubendorff": { + "name": "Bubendorff", + "integration_type": "virtual", + "supported_by": "netatmo" }, "buienradar": { + "name": "Buienradar", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Buienradar" + "iot_class": "cloud_polling" }, "caldav": { + "name": "CalDAV", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "CalDAV" + "iot_class": "cloud_polling" }, "canary": { + "name": "Canary", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Canary" + "iot_class": "cloud_polling" }, "cert_expiry": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "channels": { + "name": "Channels", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Channels" + "iot_class": "local_polling" }, "circuit": { + "name": "Unify Circuit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Unify Circuit" + "iot_class": "cloud_push" }, "cisco": { "name": "Cisco", "integrations": { "cisco_ios": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Cisco IOS" }, "cisco_mobility_express": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Cisco Mobility Express" }, "cisco_webex_teams": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Cisco Webex Teams" } } }, "citybikes": { + "name": "CityBikes", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "CityBikes" + "iot_class": "cloud_polling" }, "clementine": { + "name": "Clementine Music Player", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Clementine Music Player" + "iot_class": "local_polling" }, "clickatell": { + "name": "Clickatell", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Clickatell" + "iot_class": "cloud_push" }, "clicksend": { "name": "ClickSend", "integrations": { "clicksend": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "ClickSend SMS" }, "clicksend_tts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "ClickSend TTS" } } }, - "cloud": { - "config_flow": false, - "iot_class": "cloud_push", - "name": "Home Assistant Cloud" - }, "cloudflare": { + "name": "Cloudflare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Cloudflare" + "iot_class": "cloud_push" }, "cmus": { + "name": "cmus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "cmus" + "iot_class": "local_polling" }, "co2signal": { + "name": "CO2 Signal", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "CO2 Signal" + "iot_class": "cloud_polling" }, "coinbase": { + "name": "Coinbase", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Coinbase" + "iot_class": "cloud_polling" }, "color_extractor": { - "config_flow": false, - "iot_class": null, - "name": "ColorExtractor" + "name": "ColorExtractor", + "integration_type": "hub", + "config_flow": false }, "comed_hourly_pricing": { + "name": "ComEd Hourly Pricing", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ComEd Hourly Pricing" + "iot_class": "cloud_polling" }, "comfoconnect": { + "name": "Zehnder ComfoAir Q", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Zehnder ComfoAir Q" + "iot_class": "local_push" }, "command_line": { + "name": "Command Line", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Command Line" + "iot_class": "local_polling" }, "compensation": { + "name": "Compensation", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Compensation" + "iot_class": "calculated" }, "concord232": { + "name": "Concord232", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Concord232" + "iot_class": "local_polling" }, "control4": { + "name": "Control4", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Control4" + "iot_class": "local_polling" }, "coolmaster": { + "name": "CoolMasterNet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "CoolMasterNet" + "iot_class": "local_polling" }, "coronavirus": { + "name": "Coronavirus (COVID-19)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Coronavirus (COVID-19)" + "iot_class": "cloud_polling" + }, + "cozytouch": { + "name": "Atlantic Cozytouch", + "integration_type": "virtual", + "supported_by": "overkiz" }, "cpuspeed": { + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, "crownstone": { + "name": "Crownstone", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Crownstone" + "iot_class": "cloud_push" }, "cups": { + "name": "CUPS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "CUPS" + "iot_class": "local_polling" }, "currencylayer": { + "name": "currencylayer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "currencylayer" + "iot_class": "cloud_polling" + }, + "dacia": { + "name": "Dacia", + "integration_type": "virtual", + "supported_by": "renault" }, "daikin": { + "name": "Daikin AC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Daikin AC" + "iot_class": "local_polling" }, "danfoss_air": { + "name": "Danfoss Air", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Danfoss Air" + "iot_class": "local_polling" }, "darksky": { + "name": "Dark Sky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dark Sky" + "iot_class": "cloud_polling" }, "datadog": { + "name": "Datadog", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Datadog" + "iot_class": "local_push" }, "ddwrt": { + "name": "DD-WRT", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DD-WRT" + "iot_class": "local_polling" }, "debugpy": { + "name": "Remote Python Debugger", + "integration_type": "service", "config_flow": false, - "iot_class": "local_push", - "name": "Remote Python Debugger" + "iot_class": "local_push" }, "deconz": { + "name": "deCONZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "deCONZ" + "iot_class": "local_push" }, "decora": { + "name": "Leviton Decora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Leviton Decora" + "iot_class": "local_polling" }, "decora_wifi": { + "name": "Leviton Decora Wi-Fi", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Leviton Decora Wi-Fi" + "iot_class": "cloud_polling" }, "delijn": { + "name": "De Lijn", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "De Lijn" + "iot_class": "cloud_polling" }, "deluge": { + "name": "Deluge", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Deluge" + "iot_class": "local_polling" }, "demo": { + "integration_type": "hub", "config_flow": false, "iot_class": "calculated" }, @@ -754,16 +943,18 @@ "name": "Denon", "integrations": { "denon": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Denon Network Receivers" }, "denonavr": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Denon AVR Network Receivers" }, "heos": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Denon HEOS" @@ -771,79 +962,106 @@ } }, "deutsche_bahn": { + "name": "Deutsche Bahn", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Deutsche Bahn" + "iot_class": "cloud_polling" }, "device_sun_light_trigger": { + "name": "Presence-based Lights", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Presence-based Lights" + "iot_class": "calculated" }, "devolo": { "name": "devolo", "integrations": { "devolo_home_control": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "devolo Home Control" }, "devolo_home_network": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "devolo Home Network" } - } + }, + "iot_standards": [ + "zwave" + ] }, "dexcom": { + "name": "Dexcom", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Dexcom" + "iot_class": "cloud_polling" + }, + "diaz": { + "name": "Diaz", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "digital_loggers": { + "name": "Digital Loggers", + "integration_type": "virtual", + "supported_by": "wemo" }, "digital_ocean": { + "name": "Digital Ocean", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Digital Ocean" + "iot_class": "local_polling" }, "directv": { + "name": "DirecTV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "DirecTV" + "iot_class": "local_polling" }, "discogs": { + "name": "Discogs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Discogs" + "iot_class": "cloud_polling" }, "discord": { + "name": "Discord", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Discord" + "iot_class": "cloud_push" }, "dlib_face_detect": { + "name": "Dlib Face Detect", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Dlib Face Detect" + "iot_class": "local_push" }, "dlib_face_identify": { + "name": "Dlib Face Identify", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Dlib Face Identify" + "iot_class": "local_push" }, "dlink": { + "name": "D-Link Wi-Fi Smart Plugs", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "D-Link Wi-Fi Smart Plugs" + "iot_class": "local_polling" }, "dlna": { "name": "DLNA", "integrations": { "dlna_dmr": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "DLNA Digital Media Renderer" }, "dlna_dms": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "DLNA Digital Media Server" @@ -851,154 +1069,187 @@ } }, "dnsip": { + "name": "DNS IP", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "DNS IP" + "iot_class": "cloud_polling" }, "dominos": { + "name": "Dominos Pizza", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dominos Pizza" + "iot_class": "cloud_polling" }, "doods": { + "name": "DOODS - Dedicated Open Object Detection Service", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DOODS - Dedicated Open Object Detection Service" + "iot_class": "local_polling" }, "doorbird": { + "name": "DoorBird", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DoorBird" + "iot_class": "local_push" + }, + "dooya": { + "name": "Dooya", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "dovado": { + "name": "Dovado", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Dovado" + "iot_class": "local_polling" }, "downloader": { - "config_flow": false, - "iot_class": null, - "name": "Downloader" + "name": "Downloader", + "integration_type": "hub", + "config_flow": false }, "dsmr": { + "name": "DSMR Slimme Meter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DSMR Slimme Meter" + "iot_class": "local_push" }, "dsmr_reader": { + "name": "DSMR Reader", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DSMR Reader" + "iot_class": "local_push" }, "dte_energy_bridge": { + "name": "DTE Energy Bridge", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DTE Energy Bridge" + "iot_class": "local_polling" }, "dublin_bus_transport": { + "name": "Dublin Bus", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dublin Bus" + "iot_class": "cloud_polling" }, "duckdns": { + "name": "Duck DNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Duck DNS" + "iot_class": "cloud_polling" }, "dunehd": { + "name": "Dune HD", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Dune HD" + "iot_class": "local_polling" }, "dwd_weather_warnings": { + "name": "Deutscher Wetterdienst (DWD) Weather Warnings", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Deutscher Wetterdienst (DWD) Weather Warnings" + "iot_class": "cloud_polling" }, "dweet": { + "name": "dweet.io", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "dweet.io" + "iot_class": "cloud_polling" }, "eafm": { + "name": "Environment Agency Flood Gauges", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Environment Agency Flood Gauges" + "iot_class": "cloud_polling" }, "ebox": { + "name": "EBox", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "EBox" + "iot_class": "cloud_polling" }, "ebusd": { + "name": "ebusd", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ebusd" + "iot_class": "local_polling" }, "ecoal_boiler": { + "name": "eSterownik eCoal.pl Boiler", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "eSterownik eCoal.pl Boiler" + "iot_class": "local_polling" }, "ecobee": { + "name": "ecobee", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "ecobee" + "iot_class": "cloud_polling" }, "econet": { + "name": "Rheem EcoNet Products", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Rheem EcoNet Products" + "iot_class": "cloud_push" }, "ecovacs": { + "name": "Ecovacs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Ecovacs" + "iot_class": "cloud_push" }, "ecowitt": { + "name": "Ecowitt", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Ecowitt" + "iot_class": "local_push" }, "eddystone_temperature": { + "name": "Eddystone", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Eddystone" + "iot_class": "local_polling" }, "edimax": { + "name": "Edimax", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Edimax" + "iot_class": "local_polling" }, "edl21": { + "name": "EDL21", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "EDL21" + "iot_class": "local_push" }, "efergy": { + "name": "Efergy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Efergy" + "iot_class": "cloud_polling" }, "egardia": { + "name": "Egardia", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Egardia" + "iot_class": "local_polling" }, "eight_sleep": { + "name": "Eight Sleep", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Eight Sleep" + "iot_class": "cloud_polling" }, "elgato": { "name": "Elgato", "integrations": { "avea": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Elgato Avea" }, "elgato": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Elgato Light" @@ -1006,109 +1257,126 @@ } }, "eliqonline": { + "name": "Eliqonline", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Eliqonline" + "iot_class": "cloud_polling" }, "elkm1": { + "name": "Elk-M1 Control", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Elk-M1 Control" + "iot_class": "local_push" }, "elmax": { + "name": "Elmax", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Elmax" + "iot_class": "cloud_polling" }, "elv": { + "name": "ELV PCA", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ELV PCA" + "iot_class": "local_polling" }, "emby": { + "name": "Emby", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emby" + "iot_class": "local_push" }, "emoncms": { "name": "emoncms", "integrations": { "emoncms": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Emoncms" }, "emoncms_history": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Emoncms History" } } }, "emonitor": { + "name": "SiteSage Emonitor", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SiteSage Emonitor" + "iot_class": "local_polling" }, "emulated_hue": { + "name": "Emulated Hue", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emulated Hue" + "iot_class": "local_push" }, "emulated_kasa": { + "name": "Emulated Kasa", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emulated Kasa" + "iot_class": "local_push" }, "emulated_roku": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "enigma2": { + "name": "Enigma2 (OpenWebif)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Enigma2 (OpenWebif)" + "iot_class": "local_polling" }, "enocean": { + "name": "EnOcean", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "EnOcean" + "iot_class": "local_push" }, "enphase_envoy": { + "name": "Enphase Envoy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Enphase Envoy" + "iot_class": "local_polling" }, "entur_public_transport": { + "name": "Entur", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Entur" + "iot_class": "cloud_polling" }, "environment_canada": { + "name": "Environment Canada", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Environment Canada" + "iot_class": "cloud_polling" }, "envisalink": { + "name": "Envisalink", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Envisalink" + "iot_class": "local_push" }, "ephember": { + "name": "EPH Controls", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EPH Controls" + "iot_class": "local_polling" }, "epson": { "name": "Epson", "integrations": { "epson": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Epson" }, "epsonworkforce": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Epson Workforce" } @@ -1118,285 +1386,339 @@ "name": "eQ-3", "integrations": { "eq3btsmart": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "eQ-3 Bluetooth Smart Thermostats" }, "maxcube": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "eQ-3 MAX!" } } }, "escea": { + "name": "Escea", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Escea" + "iot_class": "local_push" }, "esphome": { + "name": "ESPHome", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "ESPHome" + "iot_class": "local_push" }, "etherscan": { + "name": "Etherscan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Etherscan" + "iot_class": "cloud_polling" }, "eufy": { + "name": "eufy", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "eufy" + "iot_class": "local_polling" }, "everlights": { + "name": "EverLights", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EverLights" + "iot_class": "local_polling" }, "evil_genius_labs": { + "name": "Evil Genius Labs", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Evil Genius Labs" + "iot_class": "local_polling" }, "ezviz": { + "name": "EZVIZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "EZVIZ" + "iot_class": "cloud_polling" }, "faa_delays": { + "name": "FAA Delays", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "FAA Delays" + "iot_class": "cloud_polling" }, "facebook": { + "name": "Facebook Messenger", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Facebook Messenger" + "iot_class": "cloud_push" }, "facebox": { + "name": "Facebox", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Facebox" + "iot_class": "local_push" }, "fail2ban": { + "name": "Fail2Ban", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Fail2Ban" + "iot_class": "local_polling" }, "fastdotcom": { + "name": "Fast.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fast.com" + "iot_class": "cloud_polling" }, "feedreader": { + "name": "Feedreader", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Feedreader" + "iot_class": "cloud_polling" }, "ffmpeg": { "name": "FFmpeg", "integrations": { "ffmpeg": { - "config_flow": false, - "iot_class": null, + "integration_type": "hub", "name": "FFmpeg" }, "ffmpeg_motion": { - "config_flow": false, + "integration_type": "hub", "iot_class": "calculated", "name": "FFmpeg Motion" }, "ffmpeg_noise": { - "config_flow": false, + "integration_type": "hub", "iot_class": "calculated", "name": "FFmpeg Noise" } } }, "fibaro": { + "name": "Fibaro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Fibaro" + "iot_class": "local_push" }, "fido": { + "name": "Fido", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fido" + "iot_class": "cloud_polling" }, "file": { + "name": "File", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "File" + "iot_class": "local_polling" }, "filesize": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "filter": { + "name": "Filter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Filter" + "iot_class": "local_push" }, "fints": { + "name": "FinTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "FinTS" + "iot_class": "cloud_polling" }, "fireservicerota": { + "name": "FireServiceRota", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "FireServiceRota" + "iot_class": "cloud_polling" }, "firmata": { + "name": "Firmata", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Firmata" + "iot_class": "local_push" }, "fitbit": { + "name": "Fitbit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fitbit" + "iot_class": "cloud_polling" }, "fivem": { + "name": "FiveM", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "FiveM" + "iot_class": "local_polling" }, "fixer": { + "name": "Fixer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fixer" + "iot_class": "cloud_polling" }, "fjaraskupan": { + "name": "Fj\u00e4r\u00e5skupan", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fj\u00e4r\u00e5skupan" + "iot_class": "local_polling" }, "fleetgo": { + "name": "FleetGO", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "FleetGO" + "iot_class": "cloud_polling" }, "flexit": { + "name": "Flexit", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Flexit" + "iot_class": "local_polling" + }, + "flexom": { + "name": "Bouygues Flexom", + "integration_type": "virtual", + "supported_by": "overkiz" }, "flic": { + "name": "Flic", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Flic" + "iot_class": "local_push" }, "flick_electric": { + "name": "Flick Electric", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flick Electric" + "iot_class": "cloud_polling" }, "flipr": { + "name": "Flipr", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flipr" + "iot_class": "cloud_polling" }, "flo": { + "name": "Flo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flo" + "iot_class": "cloud_polling" }, "flock": { + "name": "Flock", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Flock" + "iot_class": "cloud_push" }, "flume": { + "name": "Flume", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flume" + "iot_class": "cloud_polling" }, "flux": { + "name": "Flux", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Flux" + "iot_class": "calculated" }, "flux_led": { + "name": "Magic Home", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Magic Home" + "iot_class": "local_push" }, "folder": { + "name": "Folder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Folder" + "iot_class": "local_polling" }, "folder_watcher": { + "name": "Folder Watcher", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Folder Watcher" + "iot_class": "local_polling" }, "foobot": { + "name": "Foobot", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Foobot" + "iot_class": "cloud_polling" }, "forecast_solar": { + "name": "Forecast.Solar", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Forecast.Solar" + "iot_class": "cloud_polling" }, "forked_daapd": { + "name": "Owntone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "forked-daapd" + "iot_class": "local_push" }, "fortios": { + "name": "FortiOS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "FortiOS" + "iot_class": "local_polling" }, "foscam": { + "name": "Foscam", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Foscam" + "iot_class": "local_polling" }, "foursquare": { + "name": "Foursquare", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Foursquare" + "iot_class": "cloud_push" }, "free_mobile": { + "name": "Free Mobile", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Free Mobile" + "iot_class": "cloud_push" }, "freebox": { + "name": "Freebox", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Freebox" + "iot_class": "local_polling" }, "freedns": { + "name": "FreeDNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "FreeDNS" + "iot_class": "cloud_push" }, "freedompro": { + "name": "Freedompro", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Freedompro" + "iot_class": "cloud_polling" }, "fritzbox": { "name": "FRITZ!Box", "integrations": { "fritz": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!Box Tools" }, "fritzbox": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!SmartHome" }, "fritzbox_callmonitor": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!Box Call Monitor" @@ -1404,88 +1726,110 @@ } }, "fronius": { + "name": "Fronius", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fronius" + "iot_class": "local_polling" }, "frontier_silicon": { + "name": "Frontier Silicon", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Frontier Silicon" + "iot_class": "local_polling" }, "fully_kiosk": { + "name": "Fully Kiosk Browser", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fully Kiosk Browser" + "iot_class": "local_polling" }, "futurenow": { + "name": "P5 FutureNow", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "P5 FutureNow" + "iot_class": "local_polling" }, "garadget": { + "name": "Garadget", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Garadget" + "iot_class": "cloud_polling" }, "garages_amsterdam": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "gaviota": { + "name": "Gaviota", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "gdacs": { + "name": "Global Disaster Alert and Coordination System (GDACS)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Global Disaster Alert and Coordination System (GDACS)" + "iot_class": "cloud_polling" }, "generic": { + "name": "Generic Camera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Generic Camera" + "iot_class": "local_push" }, "generic_hygrostat": { + "name": "Generic hygrostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Generic hygrostat" + "iot_class": "local_polling" }, "generic_thermostat": { + "name": "Generic Thermostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Generic Thermostat" + "iot_class": "local_polling" }, "geniushub": { + "name": "Genius Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Genius Hub" + "iot_class": "local_polling" }, "geo_json_events": { + "name": "GeoJSON", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GeoJSON" + "iot_class": "cloud_polling" }, "geo_rss_events": { + "name": "GeoRSS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GeoRSS" + "iot_class": "cloud_polling" }, "geocaching": { + "name": "Geocaching", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Geocaching" + "iot_class": "cloud_polling" }, "geofency": { + "name": "Geofency", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Geofency" + "iot_class": "cloud_push" }, "geonet": { "name": "GeoNet", "integrations": { "geonetnz_quakes": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "GeoNet NZ Quakes" }, "geonetnz_volcano": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "GeoNet NZ Volcano" @@ -1493,133 +1837,149 @@ } }, "gios": { + "name": "GIO\u015a", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "GIO\u015a" + "iot_class": "cloud_polling" }, "github": { + "name": "GitHub", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "GitHub" + "iot_class": "cloud_polling" }, "gitlab_ci": { + "name": "GitLab-CI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GitLab-CI" + "iot_class": "cloud_polling" }, "gitter": { + "name": "Gitter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Gitter" + "iot_class": "cloud_polling" }, "glances": { + "name": "Glances", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Glances" + "iot_class": "local_polling" }, "globalcache": { "name": "Global Cach\u00e9", "integrations": { "gc100": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Global Cach\u00e9 GC-100" }, "itach": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "Global Cach\u00e9 iTach TCP/IP to IR" } } }, "goalfeed": { + "name": "Goalfeed", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Goalfeed" + "iot_class": "cloud_push" }, "goalzero": { + "name": "Goal Zero Yeti", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Goal Zero Yeti" + "iot_class": "local_polling" }, "gogogate2": { + "name": "Gogogate2 and ismartgate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Gogogate2 and ismartgate" + "iot_class": "local_polling" }, "goodwe": { + "name": "GoodWe Inverter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "GoodWe Inverter" + "iot_class": "local_polling" }, "google": { "name": "Google", "integrations": { "google_assistant": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Assistant" }, "google_cloud": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Cloud Platform" }, "google_domains": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Google Domains" }, "google_maps": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Google Maps" }, "google_pubsub": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Pub/Sub" }, "google_sheets": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Sheets" }, "google_translate": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Translate Text-to-Speech" }, "google_travel_time": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "google_wifi": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Google Wifi" }, "google": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Calendar" }, "nest": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Google Nest" }, "cast": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Google Cast" }, "hangouts": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Google Chat" }, "dialogflow": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Dialogflow" @@ -1627,163 +1987,200 @@ } }, "govee_ble": { + "name": "Govee Bluetooth", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Govee Bluetooth" + "iot_class": "local_push" }, "gpsd": { + "name": "GPSD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "GPSD" + "iot_class": "local_polling" }, "gpslogger": { + "name": "GPSLogger", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "GPSLogger" + "iot_class": "cloud_push" }, "graphite": { + "name": "Graphite", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Graphite" + "iot_class": "local_push" }, "gree": { + "name": "Gree Climate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Gree Climate" + "iot_class": "local_polling" }, "greeneye_monitor": { + "name": "GreenEye Monitor (GEM)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "GreenEye Monitor (GEM)" + "iot_class": "local_push" }, "greenwave": { + "name": "Greenwave Reality", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Greenwave Reality" + "iot_class": "local_polling" }, "growatt_server": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "gstreamer": { + "name": "GStreamer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "GStreamer" + "iot_class": "local_push" }, "gtfs": { + "name": "General Transit Feed Specification (GTFS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "General Transit Feed Specification (GTFS)" + "iot_class": "local_polling" }, "guardian": { + "name": "Elexa Guardian", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Elexa Guardian" + "iot_class": "local_polling" }, "habitica": { + "name": "Habitica", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Habitica" + "iot_class": "cloud_polling" }, "harman_kardon_avr": { + "name": "Harman Kardon AVR", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Harman Kardon AVR" + "iot_class": "local_polling" }, "hassio": { + "name": "Home Assistant Supervisor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Home Assistant Supervisor" + "iot_class": "local_polling" + }, + "havana_shade": { + "name": "Havana Shade", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "haveibeenpwned": { + "name": "HaveIBeenPwned", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "HaveIBeenPwned" + "iot_class": "cloud_polling" }, "hddtemp": { + "name": "hddtemp", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "hddtemp" + "iot_class": "local_polling" }, "hdmi_cec": { + "name": "HDMI-CEC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "HDMI-CEC" + "iot_class": "local_push" }, "heatmiser": { + "name": "Heatmiser", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Heatmiser" + "iot_class": "local_polling" + }, + "heiwa": { + "name": "Heiwa", + "integration_type": "virtual", + "supported_by": "gree" }, "here_travel_time": { + "name": "HERE Travel Time", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "HERE Travel Time" + "iot_class": "cloud_polling" + }, + "hi_kumo": { + "name": "Hitachi Hi Kumo", + "integration_type": "virtual", + "supported_by": "overkiz" }, "hikvision": { "name": "Hikvision", "integrations": { "hikvision": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Hikvision" }, "hikvisioncam": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Hikvision" } } }, "hisense_aehw4a1": { + "name": "Hisense AEH-W4A1", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Hisense AEH-W4A1" + "iot_class": "local_polling" }, "history_stats": { + "name": "History Stats", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "History Stats" + "iot_class": "local_polling" }, "hitron_coda": { + "name": "Rogers Hitron CODA", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Rogers Hitron CODA" + "iot_class": "local_polling" }, "hive": { + "name": "Hive", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Hive" + "iot_class": "cloud_polling" }, "hlk_sw16": { + "name": "Hi-Link HLK-SW16", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Hi-Link HLK-SW16" + "iot_class": "local_push" }, "home_connect": { + "name": "Home Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Home Connect" + "iot_class": "cloud_push" }, "home_plus_control": { + "name": "Legrand Home+ Control", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Legrand Home+ Control" - }, - "homeassistant_alerts": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Alerts" + "iot_class": "cloud_polling" }, "homematic": { "name": "Homematic", "integrations": { "homematic": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Homematic" }, "homematicip_cloud": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "HomematicIP Cloud" @@ -1791,24 +2188,27 @@ } }, "homewizard": { + "name": "HomeWizard Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "HomeWizard Energy" + "iot_class": "local_polling" }, "honeywell": { "name": "Honeywell", "integrations": { "lyric": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Lyric" }, "evohome": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (Europe)" }, "honeywell": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (US)" @@ -1816,129 +2216,172 @@ } }, "horizon": { + "name": "Unitymedia Horizon HD Recorder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Unitymedia Horizon HD Recorder" + "iot_class": "local_polling" }, "hp_ilo": { + "name": "HP Integrated Lights-Out (ILO)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "HP Integrated Lights-Out (ILO)" + "iot_class": "local_polling" }, "html5": { + "name": "HTML5 Push Notifications", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "HTML5 Push Notifications" + "iot_class": "cloud_push" }, "huawei_lte": { + "name": "Huawei LTE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Huawei LTE" + "iot_class": "local_polling" }, "huisbaasje": { + "name": "Huisbaasje", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Huisbaasje" + "iot_class": "cloud_polling" }, "hunterdouglas_powerview": { + "name": "Hunter Douglas PowerView", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Hunter Douglas PowerView" + "iot_class": "local_polling" + }, + "hurrican_shutters_wholesale": { + "name": "Hurrican Shutters Wholesale", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "hvv_departures": { + "name": "HVV Departures", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "HVV Departures" + "iot_class": "cloud_polling" }, "hydrawise": { + "name": "Hunter Hydrawise", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Hunter Hydrawise" + "iot_class": "cloud_polling" }, "hyperion": { + "name": "Hyperion", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Hyperion" + "iot_class": "local_push" }, "ialarm": { + "name": "Antifurto365 iAlarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Antifurto365 iAlarm" + "iot_class": "local_polling" }, "iammeter": { + "name": "IamMeter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "IamMeter" + "iot_class": "local_polling" }, "iaqualink": { + "name": "Jandy iAqualink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Jandy iAqualink" + "iot_class": "cloud_polling" }, "ibm": { "name": "IBM", "integrations": { "watson_iot": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "IBM Watson IoT Platform" }, "watson_tts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "IBM Watson TTS" } } }, "idteck_prox": { + "name": "IDTECK Proximity Reader", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "IDTECK Proximity Reader" + "iot_class": "local_push" }, "ifttt": { + "name": "IFTTT", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "IFTTT" + "iot_class": "cloud_push" }, "iglo": { + "name": "iGlo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "iGlo" + "iot_class": "local_polling" }, "ign_sismologia": { + "name": "IGN Sismolog\u00eda", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "IGN Sismolog\u00eda" + "iot_class": "cloud_polling" }, "ihc": { + "name": "IHC Controller", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "IHC Controller" + "iot_class": "local_push" + }, + "ikea": { + "name": "IKEA", + "integrations": { + "symfonisk": { + "integration_type": "virtual", + "supported_by": "sonos", + "name": "IKEA SYMFONISK" + }, + "tradfri": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "IKEA TR\u00c5DFRI" + } + } }, "imap": { + "name": "IMAP", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IMAP" + "iot_class": "cloud_push" }, "imap_email_content": { + "name": "IMAP Email Content", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IMAP Email Content" + "iot_class": "cloud_push" }, "incomfort": { + "name": "Intergas InComfort/Intouch Lan2RF gateway", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Intergas InComfort/Intouch Lan2RF gateway" + "iot_class": "local_polling" }, "influxdb": { + "name": "InfluxDB", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "InfluxDB" + "iot_class": "local_push" }, "inkbird": { + "name": "INKBIRD", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "INKBIRD" + "iot_class": "local_push" }, "inovelli": { "name": "Inovelli", @@ -1947,79 +2390,103 @@ "zwave" ] }, + "inspired_shades": { + "name": "Inspired Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "insteon": { + "name": "Insteon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Insteon" + "iot_class": "local_push" }, "intellifire": { + "name": "IntelliFire", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "IntelliFire" + "iot_class": "local_polling" }, "intent_script": { - "config_flow": false, - "iot_class": null, - "name": "Intent Script" + "name": "Intent Script", + "integration_type": "hub", + "config_flow": false }, "intesishome": { + "name": "IntesisHome", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IntesisHome" + "iot_class": "cloud_push" }, "ios": { + "name": "Home Assistant iOS", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Home Assistant iOS" + "iot_class": "cloud_push" }, "iotawatt": { + "name": "IoTaWatt", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "IoTaWatt" + "iot_class": "local_polling" }, "iperf3": { + "name": "Iperf3", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Iperf3" + "iot_class": "local_polling" }, "ipma": { + "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)" + "iot_class": "cloud_polling" }, "ipp": { + "name": "Internet Printing Protocol (IPP)", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Internet Printing Protocol (IPP)" + "iot_class": "local_polling" }, "iqvia": { + "name": "IQVIA", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "IQVIA" + "iot_class": "cloud_polling" }, "irish_rail_transport": { + "name": "Irish Rail Transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Irish Rail Transport" + "iot_class": "cloud_polling" }, "islamic_prayer_times": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "ismartwindow": { + "name": "iSmartWindow", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "iss": { + "name": "International Space Station (ISS)", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "International Space Station (ISS)" + "iot_class": "cloud_polling" }, "isy994": { + "name": "Universal Devices ISY994", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Universal Devices ISY994" + "iot_class": "local_push" }, "izone": { + "name": "iZone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "iZone" + "iot_class": "local_polling" }, "jasco": { "name": "Jasco", @@ -2028,179 +2495,219 @@ ] }, "jellyfin": { + "name": "Jellyfin", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "Jellyfin" + "iot_class": "local_polling" }, "jewish_calendar": { + "name": "Jewish Calendar", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Jewish Calendar" + "iot_class": "calculated" }, "joaoapps_join": { + "name": "Joaoapps Join", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Joaoapps Join" + "iot_class": "cloud_push" }, "juicenet": { + "name": "JuiceNet", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "JuiceNet" + "iot_class": "cloud_polling" }, "justnimbus": { + "name": "JustNimbus", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "JustNimbus" + "iot_class": "cloud_polling" }, "kaiterra": { + "name": "Kaiterra", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Kaiterra" + "iot_class": "cloud_polling" }, "kaleidescape": { + "name": "Kaleidescape", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kaleidescape" + "iot_class": "local_push" }, "kankun": { + "name": "Kankun", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Kankun" + "iot_class": "local_polling" }, "keba": { + "name": "Keba Charging Station", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Keba Charging Station" + "iot_class": "local_polling" }, "keenetic_ndms2": { + "name": "Keenetic NDMS2 Router", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Keenetic NDMS2 Router" + "iot_class": "local_polling" }, "kef": { + "name": "KEF", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "KEF" + "iot_class": "local_polling" }, "kegtron": { + "name": "Kegtron", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kegtron" + "iot_class": "local_push" }, "keyboard": { + "name": "Keyboard", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Keyboard" + "iot_class": "local_push" }, "keyboard_remote": { + "name": "Keyboard Remote", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Keyboard Remote" + "iot_class": "local_push" }, "keymitt_ble": { + "name": "Keymitt MicroBot Push", + "integration_type": "hub", "config_flow": true, - "iot_class": "assumed_state", - "name": "Keymitt MicroBot Push" + "iot_class": "assumed_state" }, "kira": { + "name": "Kira", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Kira" + "iot_class": "local_push" }, "kiwi": { + "name": "KIWI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "KIWI" + "iot_class": "cloud_polling" }, "kmtronic": { + "name": "KMtronic", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "KMtronic" + "iot_class": "local_push" }, "knx": { + "name": "KNX", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "KNX" + "iot_class": "local_push" }, "kodi": { + "name": "Kodi", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kodi" + "iot_class": "local_push" }, "konnected": { + "name": "Konnected.io", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Konnected.io" + "iot_class": "local_push" }, "kostal_plenticore": { + "name": "Kostal Plenticore Solar Inverter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Kostal Plenticore Solar Inverter" + "iot_class": "local_polling" }, "kraken": { + "name": "Kraken", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Kraken" + "iot_class": "cloud_polling" }, "kulersky": { + "name": "Kuler Sky", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Kuler Sky" + "iot_class": "local_polling" }, "kwb": { + "name": "KWB Easyfire", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "KWB Easyfire" + "iot_class": "local_polling" }, "lacrosse": { + "name": "LaCrosse", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "LaCrosse" + "iot_class": "local_polling" }, "lacrosse_view": { + "name": "LaCrosse View", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "LaCrosse View" + "iot_class": "cloud_polling" }, "lametric": { + "name": "LaMetric", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "LaMetric" + "iot_class": "local_polling" }, "landisgyr_heat_meter": { + "name": "Landis+Gyr Heat Meter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Landis+Gyr Heat Meter" + "iot_class": "local_polling" }, "lannouncer": { + "name": "LANnouncer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LANnouncer" + "iot_class": "local_push" }, "lastfm": { + "name": "Last.fm", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Last.fm" + "iot_class": "cloud_polling" }, "launch_library": { + "name": "Launch Library", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Launch Library" + "iot_class": "cloud_polling" }, "laundrify": { + "name": "laundrify", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "laundrify" + "iot_class": "cloud_polling" }, "lcn": { + "name": "LCN", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LCN" + "iot_class": "local_push" }, "led_ble": { + "name": "LED BLE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "LED BLE" + "iot_class": "local_polling" + }, + "legrand": { + "name": "Legrand", + "integration_type": "virtual", + "supported_by": "netatmo" }, "leviton": { "name": "Leviton", @@ -2212,16 +2719,18 @@ "name": "LG", "integrations": { "lg_netcast": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "LG Netcast" }, "lg_soundbar": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "LG Soundbars" }, "webostv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "LG webOS Smart TV" @@ -2229,108 +2738,128 @@ } }, "lidarr": { + "name": "Lidarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Lidarr" + "iot_class": "local_polling" }, "life360": { + "name": "Life360", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Life360" + "iot_class": "cloud_polling" }, "lifx": { + "name": "LIFX", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "LIFX" + "iot_class": "local_polling" }, "lifx_cloud": { + "name": "LIFX Cloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "LIFX Cloud" + "iot_class": "cloud_push" }, "lightwave": { + "name": "Lightwave", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Lightwave" + "iot_class": "assumed_state" }, "limitlessled": { + "name": "LimitlessLED", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "LimitlessLED" + "iot_class": "assumed_state" }, "linksys_smart": { + "name": "Linksys Smart Wi-Fi", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linksys Smart Wi-Fi" + "iot_class": "local_polling" }, "linode": { + "name": "Linode", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Linode" + "iot_class": "cloud_polling" }, "linux_battery": { + "name": "Linux Battery", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linux Battery" + "iot_class": "local_polling" }, "lirc": { + "name": "LIRC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LIRC" + "iot_class": "local_push" }, "litejet": { + "name": "LiteJet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "LiteJet" + "iot_class": "local_push" }, "litterrobot": { + "name": "Litter-Robot", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Litter-Robot" + "iot_class": "cloud_push" }, "llamalab_automate": { + "name": "LlamaLab Automate", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "LlamaLab Automate" + "iot_class": "cloud_push" }, "local_file": { + "name": "Local File", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Local File" + "iot_class": "local_polling" }, "local_ip": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "locative": { + "name": "Locative", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Locative" + "iot_class": "local_push" }, "logentries": { + "name": "Logentries", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Logentries" + "iot_class": "cloud_push" }, "logi_circle": { + "name": "Logi Circle", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Logi Circle" + "iot_class": "cloud_polling" }, "logitech": { "name": "Logitech", "integrations": { "harmony": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Logitech Harmony Hub" }, "ue_smart_radio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Logitech UE Smart Radio" }, "squeezebox": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Squeezebox (Logitech Media Server)" @@ -2338,825 +2867,990 @@ } }, "london_air": { + "name": "London Air", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "London Air" + "iot_class": "cloud_polling" }, "london_underground": { + "name": "London Underground", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "London Underground" + "iot_class": "cloud_polling" }, "lookin": { + "name": "LOOKin", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "LOOKin" + "iot_class": "local_push" }, "luftdaten": { + "name": "Sensor.Community", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sensor.Community" + "iot_class": "cloud_polling" }, "lupusec": { + "name": "Lupus Electronics LUPUSEC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Lupus Electronics LUPUSEC" + "iot_class": "local_polling" }, "lutron": { "name": "Lutron", "integrations": { "lutron": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Lutron" }, "lutron_caseta": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Lutron Cas\u00e9ta" }, "homeworks": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Lutron Homeworks" } } }, + "luxaflex": { + "name": "Luxaflex", + "integration_type": "virtual", + "supported_by": "hunterdouglas_powerview" + }, "lw12wifi": { + "name": "LAGUTE LW-12", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "LAGUTE LW-12" + "iot_class": "local_polling" }, "magicseaweed": { + "name": "Magicseaweed", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Magicseaweed" + "iot_class": "cloud_polling" }, "mailgun": { + "name": "Mailgun", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Mailgun" + "iot_class": "cloud_push" }, "manual": { + "name": "Manual Alarm Control Panel", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Manual Alarm Control Panel" + "iot_class": "calculated" }, - "map": { - "config_flow": false, - "iot_class": null, - "name": "Map" + "marantz": { + "name": "Marantz", + "integration_type": "virtual", + "supported_by": "denonavr" + }, + "martec": { + "name": "Martec", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "marytts": { + "name": "MaryTTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "MaryTTS" + "iot_class": "local_push" }, "mastodon": { + "name": "Mastodon", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Mastodon" + "iot_class": "cloud_push" }, "matrix": { + "name": "Matrix", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Matrix" + "iot_class": "cloud_push" }, "mazda": { + "name": "Mazda Connected Services", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Mazda Connected Services" + "iot_class": "cloud_polling" }, "meater": { + "name": "Meater", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meater" + "iot_class": "cloud_polling" }, "media_extractor": { + "name": "Media Extractor", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Media Extractor" + "iot_class": "calculated" }, "mediaroom": { + "name": "Mediaroom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mediaroom" + "iot_class": "local_polling" }, "melcloud": { + "name": "MELCloud", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "MELCloud" + "iot_class": "cloud_polling" }, "melissa": { + "name": "Melissa", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Melissa" + "iot_class": "cloud_polling" }, "melnor": { "name": "Melnor", "integrations": { "melnor": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Melnor Bluetooth" }, "raincloud": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Melnor RainCloud" } } }, "meraki": { + "name": "Meraki", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Meraki" + "iot_class": "cloud_polling" }, "message_bird": { + "name": "MessageBird", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "MessageBird" + "iot_class": "cloud_push" }, "met": { + "name": "Meteorologisk institutt (Met.no)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meteorologisk institutt (Met.no)" + "iot_class": "cloud_polling" }, "met_eireann": { + "name": "Met \u00c9ireann", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Met \u00c9ireann" + "iot_class": "cloud_polling" }, "meteo_france": { + "name": "M\u00e9t\u00e9o-France", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "M\u00e9t\u00e9o-France" + "iot_class": "cloud_polling" }, "meteoalarm": { + "name": "MeteoAlarm", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "MeteoAlarm" + "iot_class": "cloud_polling" }, "meteoclimatic": { + "name": "Meteoclimatic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meteoclimatic" + "iot_class": "cloud_polling" }, "metoffice": { + "name": "Met Office", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Met Office" + "iot_class": "cloud_polling" }, "mfi": { + "name": "Ubiquiti mFi mPort", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ubiquiti mFi mPort" + "iot_class": "local_polling" }, "microsoft": { "name": "Microsoft", "integrations": { "azure_devops": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Azure DevOps" }, "azure_event_hub": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Azure Event Hub" }, "azure_service_bus": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Azure Service Bus" }, "microsoft_face_detect": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face Detect" }, "microsoft_face_identify": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face Identify" }, "microsoft_face": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face" }, "microsoft": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Text-to-Speech (TTS)" }, "msteams": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Teams" }, "xbox": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" }, "xbox_live": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Xbox Live" } } }, "miflora": { + "name": "Mi Flora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mi Flora" + "iot_class": "local_polling" }, "mikrotik": { + "name": "Mikrotik", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Mikrotik" + "iot_class": "local_polling" }, "mill": { + "name": "Mill", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Mill" + "iot_class": "local_polling" }, "minecraft_server": { + "name": "Minecraft Server", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Minecraft Server" + "iot_class": "local_polling" }, "minio": { + "name": "Minio", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Minio" + "iot_class": "cloud_push" }, "mitemp_bt": { + "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor" + "iot_class": "local_polling" }, "mjpeg": { + "name": "MJPEG IP Camera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MJPEG IP Camera" + "iot_class": "local_push" }, "moat": { + "name": "Moat", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Moat" + "iot_class": "local_push" }, "mobile_app": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "mochad": { + "name": "Mochad", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mochad" + "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Modbus" + "iot_class": "local_polling" }, "modem_callerid": { + "name": "Phone Modem", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Phone Modem" + "iot_class": "local_polling" }, "modern_forms": { + "name": "Modern Forms", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Modern Forms" + "iot_class": "local_polling" }, "moehlenhoff_alpha2": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "mold_indicator": { + "name": "Mold Indicator", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mold Indicator" + "iot_class": "local_polling" }, "monoprice": { + "name": "Monoprice 6-Zone Amplifier", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Monoprice 6-Zone Amplifier" + "iot_class": "local_polling" }, "moon": { + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, "motion_blinds": { + "name": "Motion Blinds", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Motion Blinds" + "iot_class": "local_push" }, "motioneye": { + "name": "motionEye", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "motionEye" + "iot_class": "local_polling" }, "mpd": { + "name": "Music Player Daemon (MPD)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Music Player Daemon (MPD)" + "iot_class": "local_polling" }, "mqtt": { "name": "MQTT", "integrations": { "manual_mqtt": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Manual MQTT Alarm Control Panel" }, "mqtt": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "MQTT" }, "mqtt_eventstream": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "MQTT Eventstream" }, "mqtt_json": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT JSON" }, "mqtt_room": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT Room Presence" }, "mqtt_statestream": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT Statestream" } } }, "mullvad": { + "name": "Mullvad VPN", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Mullvad VPN" + "iot_class": "cloud_polling" }, "mutesync": { + "name": "mutesync", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "mutesync" + "iot_class": "local_polling" }, "mvglive": { + "name": "MVG", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "MVG" + "iot_class": "cloud_polling" }, "mycroft": { + "name": "Mycroft", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Mycroft" + "iot_class": "local_push" }, "myq": { + "name": "MyQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "MyQ" + "iot_class": "cloud_polling" }, "mysensors": { + "name": "MySensors", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MySensors" + "iot_class": "local_push" }, "mystrom": { + "name": "myStrom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "myStrom" + "iot_class": "local_polling" }, "mythicbeastsdns": { + "name": "Mythic Beasts DNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Mythic Beasts DNS" + "iot_class": "cloud_push" }, "nad": { + "name": "NAD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "NAD" + "iot_class": "local_polling" }, "nam": { + "name": "Nettigo Air Monitor", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Nettigo Air Monitor" + "iot_class": "local_polling" }, "namecheapdns": { + "name": "Namecheap FreeDNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Namecheap FreeDNS" + "iot_class": "cloud_push" }, "nanoleaf": { + "name": "Nanoleaf", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Nanoleaf" + "iot_class": "local_push" }, "neato": { + "name": "Neato Botvac", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Neato Botvac" + "iot_class": "cloud_polling" }, "nederlandse_spoorwegen": { + "name": "Nederlandse Spoorwegen (NS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nederlandse Spoorwegen (NS)" + "iot_class": "cloud_polling" }, "ness_alarm": { + "name": "Ness Alarm", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Ness Alarm" + "iot_class": "local_push" }, "netatmo": { + "name": "Netatmo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Netatmo" + "iot_class": "cloud_polling" }, "netdata": { + "name": "Netdata", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Netdata" + "iot_class": "local_polling" }, "netgear": { "name": "NETGEAR", "integrations": { "netgear": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "NETGEAR" }, "netgear_lte": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "NETGEAR LTE" } } }, "netio": { + "name": "Netio", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Netio" + "iot_class": "local_polling" }, "neurio_energy": { + "name": "Neurio energy", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Neurio energy" + "iot_class": "cloud_polling" }, "nexia": { + "name": "Nexia/American Standard/Trane", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Nexia/American Standard/Trane" + "iot_class": "cloud_polling" + }, + "nexity": { + "name": "Nexity Eug\u00e9nie", + "integration_type": "virtual", + "supported_by": "overkiz" }, "nextbus": { + "name": "NextBus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "NextBus" + "iot_class": "local_polling" }, "nextcloud": { + "name": "Nextcloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nextcloud" + "iot_class": "cloud_polling" }, "nextdns": { + "name": "NextDNS", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NextDNS" + "iot_class": "cloud_polling" }, "nfandroidtv": { + "name": "Notifications for Android TV / Fire TV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Notifications for Android TV / Fire TV" + "iot_class": "local_push" }, "nibe_heatpump": { + "name": "Nibe Heat Pump", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Nibe Heat Pump" + "iot_class": "local_polling" }, "nightscout": { + "name": "Nightscout", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Nightscout" + "iot_class": "cloud_polling" }, "niko_home_control": { + "name": "Niko Home Control", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Niko Home Control" + "iot_class": "local_polling" }, "nilu": { + "name": "Norwegian Institute for Air Research (NILU)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Norwegian Institute for Air Research (NILU)" + "iot_class": "cloud_polling" }, "nina": { + "name": "NINA", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NINA" + "iot_class": "cloud_polling" }, "nissan_leaf": { + "name": "Nissan Leaf", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nissan Leaf" + "iot_class": "cloud_polling" }, "nmap_tracker": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "nmbs": { + "name": "NMBS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NMBS" + "iot_class": "cloud_polling" }, "no_ip": { + "name": "No-IP.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "No-IP.com" + "iot_class": "cloud_polling" }, "noaa_tides": { + "name": "NOAA Tides", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NOAA Tides" + "iot_class": "cloud_polling" }, "nobo_hub": { + "name": "Nob\u00f8 Ecohub", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Nob\u00f8 Ecohub" + "iot_class": "local_push" }, "norway_air": { + "name": "Om Luftkvalitet i Norge (Norway Air)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Om Luftkvalitet i Norge (Norway Air)" + "iot_class": "cloud_polling" }, "notify_events": { + "name": "Notify.Events", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Notify.Events" + "iot_class": "cloud_push" }, "notion": { + "name": "Notion", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Notion" + "iot_class": "cloud_polling" }, "nsw_fuel_station": { + "name": "NSW Fuel Station Price", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NSW Fuel Station Price" + "iot_class": "cloud_polling" }, "nsw_rural_fire_service_feed": { + "name": "NSW Rural Fire Service Incidents", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NSW Rural Fire Service Incidents" + "iot_class": "cloud_polling" }, "nuheat": { + "name": "NuHeat", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NuHeat" + "iot_class": "cloud_polling" }, "nuki": { + "name": "Nuki", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Nuki" + "iot_class": "local_polling" }, "numato": { + "name": "Numato USB GPIO Expander", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Numato USB GPIO Expander" + "iot_class": "local_push" }, "nut": { + "name": "Network UPS Tools (NUT)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Network UPS Tools (NUT)" + "iot_class": "local_polling" + }, + "nutrichef": { + "name": "Nutrichef", + "integration_type": "virtual", + "supported_by": "inkbird" }, "nws": { + "name": "National Weather Service (NWS)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "National Weather Service (NWS)" + "iot_class": "cloud_polling" }, "nx584": { + "name": "NX584", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "NX584" + "iot_class": "local_push" }, "nzbget": { + "name": "NZBGet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "NZBGet" + "iot_class": "local_polling" }, "oasa_telematics": { + "name": "OASA Telematics", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OASA Telematics" + "iot_class": "cloud_polling" }, "obihai": { + "name": "Obihai", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Obihai" + "iot_class": "local_polling" }, "octoprint": { + "name": "OctoPrint", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "OctoPrint" + "iot_class": "local_polling" }, "oem": { + "name": "OpenEnergyMonitor WiFi Thermostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OpenEnergyMonitor WiFi Thermostat" + "iot_class": "local_polling" }, "ohmconnect": { + "name": "OhmConnect", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OhmConnect" + "iot_class": "cloud_polling" }, "ombi": { + "name": "Ombi", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ombi" + "iot_class": "local_polling" }, "omnilogic": { + "name": "Hayward Omnilogic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Hayward Omnilogic" + "iot_class": "cloud_polling" }, "oncue": { + "name": "Oncue by Kohler", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Oncue by Kohler" + "iot_class": "cloud_polling" }, "ondilo_ico": { + "name": "Ondilo ICO", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ondilo ICO" + "iot_class": "cloud_polling" }, "onewire": { + "name": "1-Wire", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "1-Wire" + "iot_class": "local_polling" }, "onkyo": { + "name": "Onkyo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Onkyo" + "iot_class": "local_polling" }, "onvif": { + "name": "ONVIF", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ONVIF" + "iot_class": "local_push" }, "open_meteo": { + "name": "Open-Meteo", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Open-Meteo" + "iot_class": "cloud_polling" }, "openalpr_cloud": { + "name": "OpenALPR Cloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "OpenALPR Cloud" + "iot_class": "cloud_push" }, "openalpr_local": { + "name": "OpenALPR Local", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "OpenALPR Local" + "iot_class": "local_push" }, "opencv": { + "name": "OpenCV", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "OpenCV" + "iot_class": "local_push" }, "openerz": { + "name": "Open ERZ", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Open ERZ" + "iot_class": "cloud_polling" }, "openevse": { + "name": "OpenEVSE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OpenEVSE" + "iot_class": "local_polling" }, "openexchangerates": { + "name": "Open Exchange Rates", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Open Exchange Rates" + "iot_class": "cloud_polling" }, "opengarage": { + "name": "OpenGarage", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "OpenGarage" + "iot_class": "local_polling" }, "openhardwaremonitor": { + "name": "Open Hardware Monitor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Open Hardware Monitor" + "iot_class": "local_polling" }, "openhome": { + "name": "Linn / OpenHome", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linn / OpenHome" + "iot_class": "local_polling" }, "opensensemap": { + "name": "openSenseMap", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "openSenseMap" + "iot_class": "cloud_polling" }, "opensky": { + "name": "OpenSky Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OpenSky Network" + "iot_class": "cloud_polling" }, "opentherm_gw": { + "name": "OpenTherm Gateway", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "OpenTherm Gateway" + "iot_class": "local_push" }, "openuv": { + "name": "OpenUV", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OpenUV" + "iot_class": "cloud_polling" }, "openweathermap": { + "name": "OpenWeatherMap", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OpenWeatherMap" + "iot_class": "cloud_polling" }, "openwrt": { "name": "OpenWrt", "integrations": { "luci": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "OpenWrt (luci)" }, "ubus": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "OpenWrt (ubus)" } } }, "opnsense": { + "name": "OPNSense", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OPNSense" + "iot_class": "local_polling" }, "opple": { + "name": "Opple", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Opple" + "iot_class": "local_polling" + }, + "oralb": { + "name": "Oral-B", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" }, "oru": { + "name": "Orange and Rockland Utility (ORU)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Orange and Rockland Utility (ORU)" + "iot_class": "cloud_polling" }, "orvibo": { + "name": "Orvibo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Orvibo" + "iot_class": "local_push" }, "osramlightify": { + "name": "Osramlightify", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Osramlightify" + "iot_class": "local_polling" }, "otp": { + "name": "One-Time Password (OTP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "One-Time Password (OTP)" + "iot_class": "local_polling" }, "overkiz": { + "name": "Overkiz", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Overkiz" + "iot_class": "cloud_polling" }, "ovo_energy": { + "name": "OVO Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OVO Energy" + "iot_class": "cloud_polling" }, "owntracks": { + "name": "OwnTracks", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "OwnTracks" + "iot_class": "local_push" }, "p1_monitor": { + "name": "P1 Monitor", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "P1 Monitor" + "iot_class": "local_polling" }, "panasonic": { "name": "Panasonic", "integrations": { "panasonic_bluray": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Panasonic Blu-Ray Player" }, "panasonic_viera": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Panasonic Viera" @@ -3164,49 +3858,55 @@ } }, "pandora": { + "name": "Pandora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pandora" + "iot_class": "local_polling" }, "panel_custom": { - "config_flow": false, - "iot_class": null, - "name": "Custom Panel" + "name": "Custom Panel", + "integration_type": "hub", + "config_flow": false }, "panel_iframe": { - "config_flow": false, - "iot_class": null, - "name": "iframe Panel" + "name": "iframe Panel", + "integration_type": "hub", + "config_flow": false + }, + "pcs_lighting": { + "name": "PCS Lighting", + "integration_type": "virtual", + "supported_by": "upb" }, "peco": { + "name": "PECO Outage Counter", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PECO Outage Counter" + "iot_class": "cloud_polling" }, "pencom": { + "name": "Pencom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pencom" - }, - "persistent_notification": { - "config_flow": false, - "iot_class": "local_push", - "name": "Persistent Notification" + "iot_class": "local_polling" }, "philips": { "name": "Philips", "integrations": { "dynalite": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Philips Dynalite" }, "hue": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Philips Hue" }, "philips_js": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Philips TV" @@ -3214,202 +3914,237 @@ } }, "pi_hole": { + "name": "Pi-hole", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pi-hole" + "iot_class": "local_polling" }, "picnic": { + "name": "Picnic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Picnic" + "iot_class": "cloud_polling" }, "picotts": { + "name": "Pico TTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Pico TTS" + "iot_class": "local_push" }, "pilight": { + "name": "Pilight", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Pilight" + "iot_class": "local_push" }, "ping": { + "name": "Ping (ICMP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ping (ICMP)" + "iot_class": "local_polling" }, "pioneer": { + "name": "Pioneer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pioneer" + "iot_class": "local_polling" }, "pjlink": { + "name": "PJLink", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "PJLink" + "iot_class": "local_polling" }, "plaato": { + "name": "Plaato", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Plaato" + "iot_class": "cloud_push" }, "plant": { - "config_flow": false, - "iot_class": null + "integration_type": "hub", + "config_flow": false }, "plex": { + "name": "Plex Media Server", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Plex Media Server" + "iot_class": "local_push" }, "plugwise": { + "name": "Plugwise", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Plugwise" + "iot_class": "local_polling" }, "plum_lightpad": { + "name": "Plum Lightpad", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Plum Lightpad" + "iot_class": "local_push" }, "pocketcasts": { + "name": "Pocket Casts", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Pocket Casts" + "iot_class": "cloud_polling" }, "point": { + "name": "Minut Point", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Minut Point" + "iot_class": "cloud_polling" }, "poolsense": { + "name": "PoolSense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PoolSense" + "iot_class": "cloud_polling" }, "profiler": { - "config_flow": true, - "iot_class": null, - "name": "Profiler" + "name": "Profiler", + "integration_type": "hub", + "config_flow": true }, "progettihwsw": { + "name": "ProgettiHWSW Automation", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "ProgettiHWSW Automation" + "iot_class": "local_polling" }, "proliphix": { + "name": "Proliphix", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Proliphix" + "iot_class": "local_polling" }, "prometheus": { + "name": "Prometheus", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Prometheus" + "iot_class": "assumed_state" }, "prosegur": { + "name": "Prosegur Alarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Prosegur Alarm" + "iot_class": "cloud_polling" }, "prowl": { + "name": "Prowl", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Prowl" + "iot_class": "cloud_push" }, "proximity": { + "integration_type": "hub", "config_flow": false, "iot_class": "calculated" }, "proxmoxve": { + "name": "Proxmox VE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Proxmox VE" + "iot_class": "local_polling" }, "proxy": { - "config_flow": false, - "iot_class": null, - "name": "Camera Proxy" + "name": "Camera Proxy", + "integration_type": "hub", + "config_flow": false }, "prusalink": { + "name": "PrusaLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "PrusaLink" + "iot_class": "local_polling" }, "pulseaudio_loopback": { + "name": "PulseAudio Loopback", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "PulseAudio Loopback" + "iot_class": "local_polling" }, "pure_energie": { + "name": "Pure Energie", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pure Energie" + "iot_class": "local_polling" }, "push": { + "name": "Push", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Push" + "iot_class": "local_push" }, "pushbullet": { + "name": "Pushbullet", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Pushbullet" + "iot_class": "cloud_polling" }, "pushover": { + "name": "Pushover", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Pushover" + "iot_class": "cloud_push" }, "pushsafer": { + "name": "Pushsafer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Pushsafer" + "iot_class": "cloud_push" }, "pvoutput": { + "name": "PVOutput", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PVOutput" + "iot_class": "cloud_polling" }, "pvpc_hourly_pricing": { + "name": "Spain electricity hourly pricing (PVPC)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Spain electricity hourly pricing (PVPC)" + "iot_class": "cloud_polling" }, "pyload": { + "name": "pyLoad", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "pyLoad" + "iot_class": "local_polling" }, "python_script": { - "config_flow": false, - "iot_class": null, - "name": "Python Scripts" + "name": "Python Scripts", + "integration_type": "hub", + "config_flow": false }, "qbittorrent": { + "name": "qBittorrent", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "qBittorrent" + "iot_class": "local_polling" }, "qingping": { + "name": "Qingping", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Qingping" + "iot_class": "local_push" }, "qld_bushfire": { + "name": "Queensland Bushfire Alert", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Queensland Bushfire Alert" + "iot_class": "cloud_polling" }, "qnap": { "name": "QNAP", "integrations": { "qnap": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "QNAP" }, "qnap_qsw": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "QNAP QSW" @@ -3417,268 +4152,329 @@ } }, "qrcode": { + "name": "QR Code", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "QR Code" + "iot_class": "calculated" }, "quantum_gateway": { + "name": "Quantum Gateway", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Quantum Gateway" + "iot_class": "local_polling" }, "qvr_pro": { + "name": "QVR Pro", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "QVR Pro" + "iot_class": "local_polling" }, "qwikswitch": { + "name": "QwikSwitch QSUSB", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "QwikSwitch QSUSB" + "iot_class": "local_push" }, "rachio": { + "name": "Rachio", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Rachio" + "iot_class": "cloud_push" }, "radarr": { + "name": "Radarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Radarr" + "iot_class": "local_polling" }, "radio_browser": { + "name": "Radio Browser", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Radio Browser" + "iot_class": "cloud_polling" }, "radiotherm": { + "name": "Radio Thermostat", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Radio Thermostat" + "iot_class": "local_polling" }, "rainbird": { + "name": "Rain Bird", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Rain Bird" + "iot_class": "local_polling" }, "rainforest_eagle": { + "name": "Rainforest Eagle", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Rainforest Eagle" + "iot_class": "local_polling" }, "rainmachine": { + "name": "RainMachine", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "RainMachine" + "iot_class": "local_polling" }, "random": { + "name": "Random", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Random" + "iot_class": "local_polling" }, - "raspberry": { + "raspberry_pi": { "name": "Raspberry Pi", "integrations": { "rpi_camera": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Raspberry Pi Camera" }, "rpi_power": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "remote_rpi_gpio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", - "name": "remote_rpi_gpio" + "name": "Raspberry Pi Remote GPIO" } } }, "raspyrfm": { + "name": "RaspyRFM", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "RaspyRFM" + "iot_class": "assumed_state" + }, + "raven_rock_mfg": { + "name": "Raven Rock MFG", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "rdw": { + "name": "RDW", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "RDW" + "iot_class": "cloud_polling" }, "recollect_waste": { + "name": "ReCollect Waste", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "ReCollect Waste" + "iot_class": "cloud_polling" }, "recswitch": { + "name": "Ankuoo REC Switch", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ankuoo REC Switch" + "iot_class": "local_polling" }, "reddit": { + "name": "Reddit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Reddit" + "iot_class": "cloud_polling" }, "rejseplanen": { + "name": "Rejseplanen", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Rejseplanen" + "iot_class": "cloud_polling" }, "remember_the_milk": { + "name": "Remember The Milk", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Remember The Milk" + "iot_class": "cloud_push" }, "renault": { + "name": "Renault", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Renault" + "iot_class": "cloud_polling" }, "repetier": { + "name": "Repetier-Server", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Repetier-Server" + "iot_class": "local_polling" }, "rest": { + "name": "RESTful", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "RESTful" + "iot_class": "local_polling" }, "rest_command": { + "name": "RESTful Command", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "RESTful Command" + "iot_class": "local_push" + }, + "rexel": { + "name": "Rexel Energeasy Connect", + "integration_type": "virtual", + "supported_by": "overkiz" }, "rflink": { + "name": "RFLink", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "RFLink" + "iot_class": "assumed_state" }, "rfxtrx": { + "name": "RFXCOM RFXtrx", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RFXCOM RFXtrx" + "iot_class": "local_push" }, "rhasspy": { + "name": "Rhasspy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Rhasspy" + "iot_class": "local_push" }, "ridwell": { + "name": "Ridwell", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ridwell" + "iot_class": "cloud_polling" }, "ring": { + "name": "Ring", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ring" + "iot_class": "cloud_polling" }, "ripple": { + "name": "Ripple", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Ripple" + "iot_class": "cloud_polling" }, "risco": { + "name": "Risco", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Risco" + "iot_class": "local_push" }, "rituals_perfume_genie": { + "name": "Rituals Perfume Genie", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Rituals Perfume Genie" + "iot_class": "cloud_polling" }, "rmvtransport": { + "name": "RMV", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "RMV" + "iot_class": "cloud_polling" + }, + "roborock": { + "name": "Roborock", + "integration_type": "virtual", + "supported_by": "xiaomi_miio" }, "rocketchat": { + "name": "Rocket.Chat", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Rocket.Chat" + "iot_class": "cloud_push" }, "roku": { + "name": "Roku", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Roku" + "iot_class": "local_polling" }, "roomba": { + "name": "iRobot Roomba and Braava", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "iRobot Roomba and Braava" + "iot_class": "local_push" }, "roon": { + "name": "RoonLabs music player", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RoonLabs music player" + "iot_class": "local_push" }, "rova": { + "name": "ROVA", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ROVA" + "iot_class": "cloud_polling" }, "rss_feed_template": { + "name": "RSS Feed Template", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "RSS Feed Template" + "iot_class": "local_push" }, "rtorrent": { + "name": "rTorrent", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "rTorrent" + "iot_class": "local_polling" }, "rtsp_to_webrtc": { + "name": "RTSPtoWebRTC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RTSPtoWebRTC" + "iot_class": "local_push" }, "ruckus_unleashed": { + "name": "Ruckus Unleashed", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Ruckus Unleashed" + "iot_class": "local_polling" }, "russound": { "name": "Russound", "integrations": { "russound_rio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Russound RIO" }, "russound_rnet": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Russound RNET" } } }, "sabnzbd": { + "name": "SABnzbd", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SABnzbd" + "iot_class": "local_polling" }, "saj": { + "name": "SAJ Solar Inverter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SAJ Solar Inverter" + "iot_class": "local_polling" }, "samsung": { "name": "Samsung", "integrations": { "familyhub": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Samsung Family Hub" }, "samsungtv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Samsung Smart TV" }, "syncthru": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Samsung SyncThru Printer" @@ -3686,333 +4482,437 @@ } }, "satel_integra": { + "name": "Satel Integra", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Satel Integra" + "iot_class": "local_push" }, "schluter": { + "name": "Schluter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Schluter" + "iot_class": "cloud_polling" }, "scrape": { + "name": "Scrape", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Scrape" + "iot_class": "cloud_polling" + }, + "screenaway": { + "name": "ScreenAway", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "screenlogic": { + "name": "Pentair ScreenLogic", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pentair ScreenLogic" + "iot_class": "local_polling" }, "scsgate": { + "name": "SCSGate", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SCSGate" + "iot_class": "local_polling" }, "season": { + "name": "Season", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "Season" + "iot_class": "local_polling" }, "sendgrid": { + "name": "SendGrid", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "SendGrid" + "iot_class": "cloud_push" }, "sense": { + "name": "Sense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sense" + "iot_class": "cloud_polling" }, "senseme": { + "name": "SenseME", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SenseME" + "iot_class": "local_push" }, "sensibo": { + "name": "Sensibo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sensibo" + "iot_class": "cloud_polling" + }, + "sensorblue": { + "name": "SensorBlue", + "integration_type": "virtual", + "supported_by": "thermobeacon" }, "sensorpro": { + "name": "SensorPro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SensorPro" + "iot_class": "local_push" }, "sensorpush": { + "name": "SensorPush", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SensorPush" + "iot_class": "local_push" }, "sentry": { + "name": "Sentry", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sentry" + "iot_class": "cloud_polling" }, "senz": { + "name": "nVent RAYCHEM SENZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "nVent RAYCHEM SENZ" + "iot_class": "cloud_polling" }, "serial": { + "name": "Serial", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Serial" + "iot_class": "local_polling" }, "serial_pm": { + "name": "Serial Particulate Matter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Serial Particulate Matter" + "iot_class": "local_polling" }, "sesame": { + "name": "Sesame Smart Lock", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sesame Smart Lock" + "iot_class": "cloud_polling" }, "seven_segments": { + "name": "Seven Segments OCR", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Seven Segments OCR" + "iot_class": "local_polling" }, "seventeentrack": { + "name": "17TRACK", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "17TRACK" + "iot_class": "cloud_polling" }, "sharkiq": { + "name": "Shark IQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Shark IQ" + "iot_class": "cloud_polling" }, "shell_command": { + "name": "Shell Command", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Shell Command" + "iot_class": "local_push" }, "shelly": { + "name": "Shelly", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "Shelly" + "iot_class": "local_push" }, "shiftr": { + "name": "shiftr.io", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "shiftr.io" + "iot_class": "cloud_push" }, "shodan": { + "name": "Shodan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Shodan" + "iot_class": "cloud_polling" }, "shopping_list": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "sia": { + "name": "SIA Alarm Systems", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SIA Alarm Systems" + "iot_class": "local_push" }, "sigfox": { + "name": "Sigfox", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sigfox" + "iot_class": "cloud_polling" }, "sighthound": { + "name": "Sighthound", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sighthound" + "iot_class": "cloud_polling" }, "signal_messenger": { + "name": "Signal Messenger", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Signal Messenger" + "iot_class": "cloud_push" }, "simplepush": { + "name": "Simplepush", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Simplepush" + "iot_class": "cloud_polling" }, "simplisafe": { + "name": "SimpliSafe", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SimpliSafe" + "iot_class": "cloud_polling" + }, + "simply_automated": { + "name": "Simply Automated", + "integration_type": "virtual", + "supported_by": "upb" }, "simulated": { + "name": "Simulated", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Simulated" + "iot_class": "local_polling" }, "sinch": { + "name": "Sinch SMS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Sinch SMS" + "iot_class": "cloud_push" }, "sisyphus": { + "name": "Sisyphus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Sisyphus" + "iot_class": "local_push" }, "sky_hub": { + "name": "Sky Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Sky Hub" + "iot_class": "local_polling" }, "skybeacon": { + "name": "Skybeacon", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Skybeacon" + "iot_class": "local_polling" }, "skybell": { + "name": "SkyBell", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SkyBell" + "iot_class": "cloud_polling" }, "slack": { + "name": "Slack", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_push", - "name": "Slack" + "iot_class": "cloud_push" }, "sleepiq": { + "name": "SleepIQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SleepIQ" + "iot_class": "cloud_polling" }, "slide": { + "name": "Slide", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Slide" + "iot_class": "cloud_polling" }, "slimproto": { + "name": "SlimProto (Squeezebox players)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SlimProto (Squeezebox players)" + "iot_class": "local_push" }, "sma": { + "name": "SMA Solar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SMA Solar" + "iot_class": "local_polling" }, "smappee": { + "name": "Smappee", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Smappee" + "iot_class": "cloud_polling" + }, + "smart_blinds": { + "name": "Smart Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "smart_home": { + "name": "Smart Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "smart_meter_texas": { + "name": "Smart Meter Texas", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Smart Meter Texas" + "iot_class": "cloud_polling" + }, + "smarther": { + "name": "Smarther", + "integration_type": "virtual", + "supported_by": "netatmo" }, "smartthings": { + "name": "SmartThings", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "SmartThings" + "iot_class": "cloud_push" }, "smarttub": { + "name": "SmartTub", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SmartTub" + "iot_class": "cloud_polling" }, "smarty": { + "name": "Salda Smarty", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Salda Smarty" + "iot_class": "local_polling" }, "smhi": { + "name": "SMHI", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SMHI" + "iot_class": "cloud_polling" }, "sms": { + "name": "SMS notifications via GSM-modem", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SMS notifications via GSM-modem" + "iot_class": "local_polling" }, "smtp": { + "name": "SMTP", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "SMTP" + "iot_class": "cloud_push" }, "snapcast": { + "name": "Snapcast", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Snapcast" + "iot_class": "local_polling" }, "snips": { + "name": "Snips", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Snips" + "iot_class": "local_push" }, "snmp": { + "name": "SNMP", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SNMP" + "iot_class": "local_polling" + }, + "snooz": { + "name": "Snooz", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" }, "solaredge": { "name": "SolarEdge", "integrations": { "solaredge": { + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling", "name": "SolarEdge" }, "solaredge_local": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "SolarEdge Local" } } }, "solarlog": { + "name": "Solar-Log", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Solar-Log" + "iot_class": "local_polling" }, "solax": { + "name": "SolaX Power", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SolaX Power" + "iot_class": "local_polling" }, "soma": { + "name": "Soma Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Soma Connect" + "iot_class": "local_polling" + }, + "somfy": { + "name": "Somfy", + "integration_type": "virtual", + "supported_by": "overkiz" }, "somfy_mylink": { + "name": "Somfy MyLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "assumed_state", - "name": "Somfy MyLink" + "iot_class": "assumed_state" }, "sonarr": { + "name": "Sonarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Sonarr" + "iot_class": "local_polling" }, "sonos": { + "name": "Sonos", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Sonos" + "iot_class": "local_push" }, "sony": { "name": "Sony", "integrations": { "braviatv": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Sony Bravia TV" }, "ps4": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Sony PlayStation 4" }, "sony_projector": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Sony Projector" }, "songpal": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Sony Songpal" @@ -4020,273 +4920,309 @@ } }, "soundtouch": { + "name": "Bose SoundTouch", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Bose SoundTouch" + "iot_class": "local_polling" }, "spaceapi": { + "name": "Space API", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Space API" + "iot_class": "cloud_polling" }, "spc": { + "name": "Vanderbilt SPC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Vanderbilt SPC" + "iot_class": "local_push" }, "speedtestdotnet": { + "name": "Speedtest.net", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Speedtest.net" + "iot_class": "cloud_polling" }, "spider": { + "name": "Itho Daalderop Spider", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Itho Daalderop Spider" + "iot_class": "cloud_polling" }, "splunk": { + "name": "Splunk", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Splunk" + "iot_class": "local_push" }, "spotify": { + "name": "Spotify", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Spotify" + "iot_class": "cloud_polling" }, "sql": { + "name": "SQL", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SQL" + "iot_class": "local_polling" }, "srp_energy": { + "name": "SRP Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SRP Energy" + "iot_class": "cloud_polling" }, "starline": { + "name": "StarLine", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "StarLine" + "iot_class": "cloud_polling" }, "starlingbank": { + "name": "Starling Bank", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Starling Bank" + "iot_class": "cloud_polling" }, "startca": { + "name": "Start.ca", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Start.ca" + "iot_class": "cloud_polling" }, "statistics": { + "name": "Statistics", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Statistics" + "iot_class": "local_polling" }, "statsd": { + "name": "StatsD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "StatsD" + "iot_class": "local_push" }, "steam_online": { + "name": "Steam", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Steam" + "iot_class": "cloud_polling" }, "steamist": { + "name": "Steamist", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Steamist" + "iot_class": "local_polling" }, "stiebel_eltron": { + "name": "STIEBEL ELTRON", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "STIEBEL ELTRON" + "iot_class": "local_polling" }, "stookalert": { + "name": "RIVM Stookalert", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "RIVM Stookalert" - }, - "stream": { - "config_flow": false, - "iot_class": "local_push", - "name": "Stream" + "iot_class": "cloud_polling" }, "streamlabswater": { + "name": "StreamLabs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "StreamLabs" + "iot_class": "cloud_polling" }, "subaru": { + "name": "Subaru", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Subaru" + "iot_class": "cloud_polling" }, "suez_water": { + "name": "Suez Water", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Suez Water" + "iot_class": "cloud_polling" }, "sun": { + "integration_type": "hub", "config_flow": true, "iot_class": "calculated" }, "supervisord": { + "name": "Supervisord", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Supervisord" + "iot_class": "local_polling" }, "supla": { + "name": "Supla", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Supla" + "iot_class": "cloud_polling" }, "surepetcare": { + "name": "Sure Petcare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sure Petcare" + "iot_class": "cloud_polling" }, "swiss_hydrological_data": { + "name": "Swiss Hydrological Data", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Swiss Hydrological Data" + "iot_class": "cloud_polling" }, "swiss_public_transport": { + "name": "Swiss public transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Swiss public transport" + "iot_class": "cloud_polling" }, "swisscom": { + "name": "Swisscom Internet-Box", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Swisscom Internet-Box" + "iot_class": "local_polling" }, "switchbee": { + "name": "SwitchBee", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SwitchBee" + "iot_class": "local_polling" }, "switchbot": { + "name": "SwitchBot", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SwitchBot" + "iot_class": "local_push" }, "switcher_kis": { + "name": "Switcher", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Switcher" + "iot_class": "local_push" }, "switchmate": { + "name": "Switchmate SimplySmart Home", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Switchmate SimplySmart Home" + "iot_class": "local_polling" }, "syncthing": { + "name": "Syncthing", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Syncthing" + "iot_class": "local_polling" }, "synology": { "name": "Synology", "integrations": { "synology_chat": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Synology Chat" }, "synology_dsm": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Synology DSM" }, "synology_srm": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Synology SRM" } } }, "syslog": { + "name": "Syslog", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Syslog" + "iot_class": "local_push" }, "system_bridge": { + "name": "System Bridge", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "System Bridge" - }, - "system_log": { - "config_flow": false, - "iot_class": null, - "name": "System Log" + "iot_class": "local_push" }, "systemmonitor": { + "name": "System Monitor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "System Monitor" + "iot_class": "local_push" }, "tado": { + "name": "Tado", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tado" + "iot_class": "cloud_polling" }, "tag": { - "config_flow": false, - "iot_class": null + "integration_type": "hub", + "config_flow": false }, "tailscale": { + "name": "Tailscale", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tailscale" + "iot_class": "cloud_polling" }, "tank_utility": { + "name": "Tank Utility", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Tank Utility" + "iot_class": "cloud_polling" }, "tankerkoenig": { + "name": "Tankerkoenig", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tankerkoenig" + "iot_class": "cloud_polling" }, "tapsaff": { + "name": "Taps Aff", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Taps Aff" + "iot_class": "local_polling" }, "tasmota": { + "name": "Tasmota", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Tasmota" + "iot_class": "local_push" }, "tautulli": { + "name": "Tautulli", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Tautulli" + "iot_class": "local_polling" }, "tcp": { + "name": "TCP", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TCP" + "iot_class": "local_polling" }, "ted5000": { + "name": "The Energy Detective TED5000", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "The Energy Detective TED5000" + "iot_class": "local_polling" }, "telegram": { "name": "Telegram", "integrations": { "telegram": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Telegram" }, "telegram_bot": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Telegram bot" } @@ -4296,46 +5232,53 @@ "name": "Telldus", "integrations": { "tellduslive": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Telldus Live" }, "tellstick": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "TellStick" } } }, "telnet": { + "name": "Telnet", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Telnet" + "iot_class": "local_polling" }, "temper": { + "name": "TEMPer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TEMPer" + "iot_class": "local_polling" }, "template": { + "name": "Template", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Template" + "iot_class": "local_push" }, "tensorflow": { + "name": "TensorFlow", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TensorFlow" + "iot_class": "local_polling" }, "tesla": { "name": "Tesla", "integrations": { "powerwall": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Tesla Powerwall" }, "tesla_wall_connector": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Tesla Wall Connector" @@ -4343,39 +5286,51 @@ } }, "tfiac": { + "name": "Tfiac", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tfiac" + "iot_class": "local_polling" }, "thermobeacon": { + "name": "ThermoBeacon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ThermoBeacon" + "iot_class": "local_push" + }, + "thermoplus": { + "name": "ThermoPlus", + "integration_type": "virtual", + "supported_by": "thermobeacon" }, "thermopro": { + "name": "ThermoPro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ThermoPro" + "iot_class": "local_push" }, "thermoworks_smoke": { + "name": "ThermoWorks Smoke", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ThermoWorks Smoke" + "iot_class": "cloud_polling" }, "thethingsnetwork": { + "name": "The Things Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "The Things Network" + "iot_class": "local_push" }, "thingspeak": { + "name": "ThingSpeak", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "ThingSpeak" + "iot_class": "cloud_push" }, "thinkingcleaner": { + "name": "Thinking Cleaner", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Thinking Cleaner" + "iot_class": "local_polling" }, "third_reality": { "name": "Third Reality", @@ -4384,119 +5339,136 @@ ] }, "thomson": { + "name": "Thomson", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Thomson" + "iot_class": "local_polling" }, "tibber": { + "name": "Tibber", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tibber" + "iot_class": "cloud_polling" }, "tikteck": { + "name": "Tikteck", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tikteck" + "iot_class": "local_polling" }, "tile": { + "name": "Tile", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tile" + "iot_class": "cloud_polling" }, "tilt_ble": { + "name": "Tilt Hydrometer BLE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Tilt Hydrometer BLE" + "iot_class": "local_push" }, "time_date": { + "name": "Time & Date", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Time & Date" + "iot_class": "local_push" }, "tmb": { + "name": "Transports Metropolitans de Barcelona", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Transports Metropolitans de Barcelona" + "iot_class": "local_polling" }, "todoist": { + "name": "Todoist", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Todoist" + "iot_class": "cloud_polling" }, "tolo": { + "name": "TOLO Sauna", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "TOLO Sauna" + "iot_class": "local_polling" }, "tomato": { + "name": "Tomato", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tomato" + "iot_class": "local_polling" }, "tomorrowio": { + "name": "Tomorrow.io", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tomorrow.io" + "iot_class": "cloud_polling" }, "toon": { + "name": "Toon", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Toon" + "iot_class": "cloud_push" }, "torque": { + "name": "Torque", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Torque" + "iot_class": "cloud_polling" }, "totalconnect": { + "name": "Total Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Total Connect" + "iot_class": "cloud_polling" }, "touchline": { + "name": "Roth Touchline", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Roth Touchline" + "iot_class": "local_polling" }, "tplink": { + "name": "TP-Link Kasa Smart", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "TP-Link Kasa Smart" + "iot_class": "local_polling" }, "tplink_lte": { + "name": "TP-Link LTE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TP-Link LTE" + "iot_class": "local_polling" }, "traccar": { + "name": "Traccar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Traccar" + "iot_class": "local_polling" }, "tractive": { + "name": "Tractive", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_push", - "name": "Tractive" - }, - "tradfri": { - "config_flow": true, - "iot_class": "local_polling", - "name": "IKEA TR\u00c5DFRI" + "iot_class": "cloud_push" }, "trafikverket": { "name": "Trafikverket", "integrations": { "trafikverket_ferry": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Ferry" }, "trafikverket_train": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Train" }, "trafikverket_weatherstation": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Weather Station" @@ -4504,95 +5476,113 @@ } }, "transmission": { + "name": "Transmission", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Transmission" + "iot_class": "local_polling" }, "transport_nsw": { + "name": "Transport NSW", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Transport NSW" + "iot_class": "cloud_polling" }, "travisci": { + "name": "Travis-CI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Travis-CI" + "iot_class": "cloud_polling" }, "trend": { + "name": "Trend", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Trend" + "iot_class": "local_push" }, "tuya": { + "name": "Tuya", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Tuya" + "iot_class": "cloud_push" }, "twentemilieu": { + "name": "Twente Milieu", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Twente Milieu" + "iot_class": "cloud_polling" }, "twilio": { "name": "Twilio", "integrations": { "twilio": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Twilio" }, "twilio_call": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Twilio Call" }, "twilio_sms": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Twilio SMS" } } }, "twinkly": { + "name": "Twinkly", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Twinkly" + "iot_class": "local_polling" }, "twitch": { + "name": "Twitch", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Twitch" + "iot_class": "cloud_polling" }, "twitter": { + "name": "Twitter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Twitter" + "iot_class": "cloud_push" }, "u_tec": { "name": "U-tec", - "iot_standards": [ - "zwave" - ] + "integrations": { + "ultraloq": { + "integration_type": "virtual", + "iot_standards": [ + "zwave" + ], + "name": "Ultraloq" + } + } }, "ubiquiti": { "name": "Ubiquiti", "integrations": { "unifi": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "UniFi Network" }, "unifi_direct": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "UniFi AP" }, "unifiled": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "UniFi LED" }, "unifiprotect": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "UniFi Protect" @@ -4600,143 +5590,175 @@ } }, "uk_transport": { + "name": "UK Transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "UK Transport" + "iot_class": "cloud_polling" }, "ukraine_alarm": { + "name": "Ukraine Alarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ukraine Alarm" + "iot_class": "cloud_polling" }, "universal": { + "name": "Universal Media Player", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Universal Media Player" + "iot_class": "calculated" }, "upb": { + "name": "Universal Powerline Bus (UPB)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Universal Powerline Bus (UPB)" + "iot_class": "local_push" }, "upc_connect": { + "name": "UPC Connect Box", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "UPC Connect Box" + "iot_class": "local_polling" }, "upcloud": { + "name": "UpCloud", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "UpCloud" + "iot_class": "cloud_polling" }, "upnp": { + "name": "UPnP/IGD", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "UPnP/IGD" + "iot_class": "local_polling" + }, + "uprise_smart_shades": { + "name": "Uprise Smart Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "uptime": { + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, "uptimerobot": { + "name": "UptimeRobot", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "UptimeRobot" + "iot_class": "cloud_polling" }, "usgs_earthquakes_feed": { + "name": "U.S. Geological Survey Earthquake Hazards (USGS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "U.S. Geological Survey Earthquake Hazards (USGS)" + "iot_class": "cloud_polling" }, "uvc": { + "name": "Ubiquiti UniFi Video", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ubiquiti UniFi Video" + "iot_class": "local_polling" }, "vallox": { + "name": "Vallox", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vallox" + "iot_class": "local_polling" }, "vasttrafik": { + "name": "V\u00e4sttrafik", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "V\u00e4sttrafik" + "iot_class": "cloud_polling" }, "velbus": { + "name": "Velbus", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Velbus" + "iot_class": "local_push" }, "velux": { + "name": "Velux", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Velux" + "iot_class": "local_polling" }, "venstar": { + "name": "Venstar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Venstar" + "iot_class": "local_polling" }, "vera": { + "name": "Vera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vera" + "iot_class": "local_polling" }, "verisure": { + "name": "Verisure", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Verisure" + "iot_class": "cloud_polling" }, "versasense": { + "name": "VersaSense", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "VersaSense" + "iot_class": "local_polling" }, "version": { + "name": "Version", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Version" + "iot_class": "local_push" }, "vesync": { + "name": "VeSync", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "VeSync" + "iot_class": "cloud_polling" }, "viaggiatreno": { + "name": "Trenitalia ViaggiaTreno", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Trenitalia ViaggiaTreno" + "iot_class": "cloud_polling" }, "vicare": { + "name": "Viessmann ViCare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Viessmann ViCare" + "iot_class": "cloud_polling" }, "vilfo": { + "name": "Vilfo Router", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vilfo Router" + "iot_class": "local_polling" }, "vivotek": { + "name": "VIVOTEK", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "VIVOTEK" + "iot_class": "local_polling" }, "vizio": { + "name": "VIZIO SmartCast", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "VIZIO SmartCast" + "iot_class": "local_polling" }, "vlc": { "name": "VideoLAN", "integrations": { "vlc": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "VLC media player" }, "vlc_telnet": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "VLC media player via Telnet" @@ -4744,218 +5766,257 @@ } }, "voicerss": { + "name": "VoiceRSS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "VoiceRSS" + "iot_class": "cloud_push" }, "volkszaehler": { + "name": "Volkszaehler", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Volkszaehler" + "iot_class": "local_polling" }, "volumio": { + "name": "Volumio", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Volumio" + "iot_class": "local_polling" }, "volvooncall": { + "name": "Volvo On Call", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Volvo On Call" + "iot_class": "cloud_polling" }, "vulcan": { + "name": "Uonet+ Vulcan", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Uonet+ Vulcan" + "iot_class": "cloud_polling" }, "vultr": { + "name": "Vultr", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Vultr" + "iot_class": "cloud_polling" }, "w800rf32": { + "name": "WGL Designs W800RF32", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "WGL Designs W800RF32" + "iot_class": "local_push" }, "wake_on_lan": { + "name": "Wake on LAN", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Wake on LAN" + "iot_class": "local_push" }, "wallbox": { + "name": "Wallbox", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Wallbox" + "iot_class": "cloud_polling" }, "waqi": { + "name": "World Air Quality Index (WAQI)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "World Air Quality Index (WAQI)" + "iot_class": "cloud_polling" }, "waterfurnace": { + "name": "WaterFurnace", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "WaterFurnace" + "iot_class": "cloud_polling" }, "watttime": { + "name": "WattTime", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "WattTime" + "iot_class": "cloud_polling" }, "waze_travel_time": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "webhook": { - "config_flow": false, - "iot_class": null, - "name": "Webhook" + "name": "Webhook", + "integration_type": "hub", + "config_flow": false }, "wemo": { + "name": "Belkin WeMo", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Belkin WeMo" + "iot_class": "local_push" }, "whirlpool": { + "name": "Whirlpool Sixth Sense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Whirlpool Sixth Sense" + "iot_class": "cloud_push" }, "whois": { + "name": "Whois", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Whois" + "iot_class": "cloud_polling" }, "wiffi": { + "name": "Wiffi", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Wiffi" + "iot_class": "local_push" }, "wilight": { + "name": "WiLight", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "WiLight" + "iot_class": "local_polling" }, "wirelesstag": { + "name": "Wireless Sensor Tags", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Wireless Sensor Tags" + "iot_class": "cloud_push" }, "withings": { + "name": "Withings", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Withings" + "iot_class": "cloud_polling" }, "wiz": { + "name": "WiZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "WiZ" + "iot_class": "local_push" }, "wled": { + "name": "WLED", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "WLED" + "iot_class": "local_push" }, "wolflink": { + "name": "Wolf SmartSet Service", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Wolf SmartSet Service" + "iot_class": "cloud_polling" }, "workday": { + "name": "Workday", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Workday" + "iot_class": "local_polling" }, "worldclock": { + "name": "Worldclock", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Worldclock" + "iot_class": "local_push" }, "worldtidesinfo": { + "name": "World Tides", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "World Tides" + "iot_class": "cloud_polling" }, "worxlandroid": { + "name": "Worx Landroid", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Worx Landroid" + "iot_class": "local_polling" }, "ws66i": { + "name": "Soundavo WS66i 6-Zone Amplifier", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Soundavo WS66i 6-Zone Amplifier" + "iot_class": "local_polling" }, "wsdot": { + "name": "Washington State Department of Transportation (WSDOT)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Washington State Department of Transportation (WSDOT)" + "iot_class": "cloud_polling" }, "x10": { + "name": "Heyu X10", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Heyu X10" + "iot_class": "local_polling" }, "xeoma": { + "name": "Xeoma", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Xeoma" + "iot_class": "local_polling" }, "xiaomi": { "name": "Xiaomi", "integrations": { "xiaomi_aqara": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Xiaomi Gateway (Aqara)" }, "xiaomi_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Xiaomi BLE" }, "xiaomi_miio": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Xiaomi Miio" }, "xiaomi_tv": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "Xiaomi TV" }, "xiaomi": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Xiaomi" } } }, "xmpp": { + "name": "Jabber (XMPP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Jabber (XMPP)" + "iot_class": "cloud_push" }, "xs1": { + "name": "EZcontrol XS1", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EZcontrol XS1" + "iot_class": "local_polling" }, "yale": { "name": "Yale", "integrations": { "august": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "August" }, "yale_smart_alarm": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Yale Smart Living" }, "yalexs_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" @@ -4963,25 +6024,27 @@ } }, "yamaha": { + "name": "Yamaha Network Receivers", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Yamaha Network Receivers" + "iot_class": "local_polling" }, "yamaha_musiccast": { + "name": "MusicCast", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MusicCast" + "iot_class": "local_push" }, "yandex": { "name": "Yandex", "integrations": { "yandex_transport": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Yandex Transport" }, "yandextts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Yandex TTS" } @@ -4991,86 +6054,95 @@ "name": "Yeelight", "integrations": { "yeelight": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yeelight" }, "yeelightsunflower": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Yeelight Sunflower" } } }, "yi": { + "name": "Yi Home Cameras", + "integration_type": "device", "config_flow": false, - "iot_class": "local_polling", - "name": "Yi Home Cameras" + "iot_class": "local_polling" }, "yolink": { + "name": "YoLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "YoLink" + "iot_class": "cloud_push" }, "youless": { + "name": "YouLess", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "YouLess" + "iot_class": "local_polling" }, "zabbix": { + "name": "Zabbix", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zabbix" + "iot_class": "local_polling" }, "zamg": { - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)" + "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" }, "zengge": { + "name": "Zengge", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zengge" + "iot_class": "local_polling" }, "zerproc": { + "name": "Zerproc", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Zerproc" + "iot_class": "local_polling" }, "zestimate": { + "name": "Zestimate", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Zestimate" + "iot_class": "cloud_polling" }, "zha": { + "name": "Zigbee Home Automation", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Zigbee Home Automation" + "iot_class": "local_polling" }, "zhong_hong": { + "name": "ZhongHong", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "ZhongHong" + "iot_class": "local_push" }, "ziggo_mediabox_xl": { + "name": "Ziggo Mediabox XL", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ziggo Mediabox XL" + "iot_class": "local_polling" }, "zodiac": { + "name": "Zodiac", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zodiac" - }, - "zone": { - "config_flow": false, - "iot_class": null, - "name": "Zone" + "iot_class": "local_polling" }, "zoneminder": { + "name": "ZoneMinder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ZoneMinder" + "iot_class": "local_polling" }, "zooz": { "name": "Zooz", @@ -5079,107 +6151,95 @@ ] }, "zwave_js": { + "name": "Z-Wave", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Z-Wave" + "iot_class": "local_push" }, "zwave_me": { + "name": "Z-Wave.Me", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Z-Wave.Me" - } - }, - "hardware": { - "hardkernel": { - "config_flow": false, - "iot_class": null, - "name": "Hardkernel" - }, - "homeassistant_sky_connect": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Sky Connect" - }, - "homeassistant_yellow": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Yellow" - }, - "raspberry_pi": { - "config_flow": false, - "iot_class": null, - "name": "Raspberry Pi" + "iot_class": "local_push" } }, "helper": { "counter": { - "config_flow": false, - "iot_class": null, - "name": "Counter" + "name": "Counter", + "integration_type": "helper", + "config_flow": false }, "derivative": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "group": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "input_boolean": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_button": { - "config_flow": false, - "iot_class": null, - "name": "Input Button" + "name": "Input Button", + "integration_type": "helper", + "config_flow": false }, "input_datetime": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_number": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_select": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_text": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "integration": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "min_max": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "schedule": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "switch_as_x": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "threshold": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_polling" }, "timer": { - "config_flow": false, - "iot_class": null, - "name": "Timer" + "name": "Timer", + "integration_type": "helper", + "config_flow": false }, "tod": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "utility_meter": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/generated/supported_brands.py b/homeassistant/generated/supported_brands.py deleted file mode 100644 index 15f2a580a295e6..00000000000000 --- a/homeassistant/generated/supported_brands.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Automatically generated by hassfest. - -To update, run python3 -m script.hassfest -""" - -HAS_SUPPORTED_BRANDS = [ - "denonavr", - "hunterdouglas_powerview", - "inkbird", - "motion_blinds", - "netatmo", - "overkiz", - "renault", - "switchbee", - "thermobeacon", - "wemo", - "yalexs_ble", -] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 18ac7112bebd96..fc0c3ea5fa7a9e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -242,14 +242,29 @@ "name": "gateway*", }, ], - "_leap._tcp.local.": [ + "_lookin._tcp.local.": [ { - "domain": "lutron_caseta", + "domain": "lookin", }, ], - "_lookin._tcp.local.": [ + "_lutron._tcp.local.": [ { - "domain": "lookin", + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "radiora3*", + }, + }, + { + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "smartbridge*", + }, + }, + { + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "ra2select*", + }, }, ], "_mediaremotetv._tcp.local.": [ diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index a628cdefff49c3..387d2ad09b077e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -586,31 +586,46 @@ def sun( before_offset = before_offset or timedelta(0) after_offset = after_offset or timedelta(0) - sunrise_today = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) - sunset_today = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) - - sunrise = sunrise_today - sunset = sunset_today - if today > dt_util.as_local( - cast(datetime, sunrise_today) - ).date() and SUN_EVENT_SUNRISE in (before, after): - tomorrow = dt_util.as_local(utcnow + timedelta(days=1)).date() - sunrise_tomorrow = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) - sunrise = sunrise_tomorrow - - if today > dt_util.as_local( - cast(datetime, sunset_today) - ).date() and SUN_EVENT_SUNSET in (before, after): - tomorrow = dt_util.as_local(utcnow + timedelta(days=1)).date() - sunset_tomorrow = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) - sunset = sunset_tomorrow - - if sunrise is None and SUN_EVENT_SUNRISE in (before, after): + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + + has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) + has_sunset_condition = SUN_EVENT_SUNSET in (before, after) + + after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() + if after_sunrise and has_sunrise_condition: + tomorrow = today + timedelta(days=1) + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) + + after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() + if after_sunset and has_sunset_condition: + tomorrow = today + timedelta(days=1) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) + + # Special case: before sunrise OR after sunset + # This will handle the very rare case in the polar region when the sun rises/sets + # but does not set/rise. + # However this entire condition does not handle those full days of darkness or light, + # the following should be used instead: + # + # condition: + # condition: state + # entity_id: sun.sun + # state: 'above_horizon' (or 'below_horizon') + # + if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + return utcnow < wanted_time_before or utcnow > wanted_time_after + + if sunrise is None and has_sunrise_condition: # There is no sunrise today condition_trace_set_result(False, message="no sunrise today") return False - if sunset is None and SUN_EVENT_SUNSET in (before, after): + if sunset is None and has_sunset_condition: # There is no sunset today condition_trace_set_result(False, message="no sunset today") return False diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f6e77ef0018951..35191d7704245e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -71,8 +71,6 @@ CONF_TARGET, CONF_THEN, CONF_TIMEOUT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, CONF_UNTIL, CONF_VALUE_TEMPLATE, CONF_VARIABLES, @@ -588,11 +586,6 @@ def temperature_unit(value: Any) -> str: raise vol.Invalid("invalid temperature unit (expected C or F)") -unit_system = vol.All( - vol.Lower, vol.Any(CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL) -) - - def template(value: Any | None) -> template_helper.Template: """Validate a jinja2 template.""" if value is None: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 2f2588367b63aa..57cfe362231491 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -340,6 +340,18 @@ def capability_attributes(self) -> Mapping[str, Any] | None: """ return self._attr_capability_attributes + def get_initial_entity_options(self) -> er.EntityOptionsType | None: + """Return initial entity options. + + These will be stored in the entity registry the first time the entity is seen, + and then never updated. + + Implemented by component base class, should not be extended by integrations. + + Note: Not a property to avoid calculating unless needed. + """ + return None + @property def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes. diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 86d5436e2878d7..eea0a9943a35de 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -37,6 +37,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: """Trigger an update for an entity.""" domain = entity_id.split(".", 1)[0] + entity_comp: EntityComponent[entity.Entity] | None entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain) if entity_comp is None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 81487bbb627b3a..9b8e1985930bc9 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -607,9 +607,10 @@ async def _async_add_entity( # noqa: C901 device_id=device_id, disabled_by=disabled_by, entity_category=entity.entity_category, + get_initial_options=entity.get_initial_entity_options, + has_entity_name=entity.has_entity_name, hidden_by=hidden_by, known_object_ids=self.entities.keys(), - has_entity_name=entity.has_entity_name, original_device_class=entity.device_class, original_icon=entity.icon, original_name=entity.name, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index e58dde19127bbb..77a0b5a0400e58 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -94,6 +94,9 @@ class RegistryEntryHider(StrEnum): USER = "user" +EntityOptionsType = Mapping[str, Mapping[str, Any]] + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -114,7 +117,7 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: Mapping[str, Mapping[str, Any]] = attr.ib( + options: EntityOptionsType = attr.ib( default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] ) # As set by integration @@ -397,6 +400,8 @@ def async_get_or_create( # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, hidden_by: RegistryEntryHider | None = None, + # Function to generate initial entity options if it gets created + get_initial_options: Callable[[], EntityOptionsType | None] | None = None, # Data that we want entry to have capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry: ConfigEntry | None | UndefinedType = UNDEFINED, @@ -465,6 +470,8 @@ def none_if_undefined(value: T | UndefinedType) -> T | None: """Return None if value is UNDEFINED, otherwise return value.""" return None if value is UNDEFINED else value + initial_options = get_initial_options() if get_initial_options else None + entry = RegistryEntry( capabilities=none_if_undefined(capabilities), config_entry_id=none_if_undefined(config_entry_id), @@ -474,6 +481,7 @@ def none_if_undefined(value: T | UndefinedType) -> T | None: entity_id=entity_id, hidden_by=hidden_by, has_entity_name=none_if_undefined(has_entity_name) or False, + options=initial_options, original_device_class=none_if_undefined(original_device_class), original_icon=none_if_undefined(original_icon), original_name=none_if_undefined(original_name), @@ -590,7 +598,7 @@ def _async_update_entity( supported_features: int | UndefinedType = UNDEFINED, unit_of_measurement: str | None | UndefinedType = UNDEFINED, platform: str | None | UndefinedType = UNDEFINED, - options: Mapping[str, Mapping[str, Any]] | UndefinedType = UNDEFINED, + options: EntityOptionsType | UndefinedType = UNDEFINED, ) -> RegistryEntry: """Private facing update properties method.""" old = self.entities[entity_id] @@ -779,7 +787,7 @@ def async_update_entity_options( ) -> RegistryEntry: """Update entity options.""" old = self.entities[entity_id] - new_options: Mapping[str, Mapping[str, Any]] = {**old.options, domain: options} + new_options: EntityOptionsType = {**old.options, domain: options} return self._async_update_entity(entity_id, options=new_options) async def async_load(self) -> None: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 107567c98ce88b..613b6fb322716a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -806,7 +806,7 @@ def _template_changed_listener( track_template = threaded_listener_factory(async_track_template) -class _TrackTemplateResultInfo: +class TrackTemplateResultInfo: """Handle removal / refresh of tracker.""" def __init__( @@ -1145,7 +1145,7 @@ def async_track_template_result( raise_on_template_error: bool = False, strict: bool = False, has_super_template: bool = False, -) -> _TrackTemplateResultInfo: +) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. The action will fire with the initial result from the template, and @@ -1184,9 +1184,7 @@ def async_track_template_result( Info object used to unregister the listener, and refresh the template. """ - tracker = _TrackTemplateResultInfo( - hass, track_templates, action, has_super_template - ) + tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) tracker.async_setup(raise_on_template_error, strict=strict) return tracker diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 32e08874a63e06..5545aa09f01e3a 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -38,7 +38,6 @@ async def async_wait_recorder(hass: HomeAssistant) -> bool: Returns False immediately if the recorder is not enabled. """ - # pylint: disable-next=import-outside-toplevel if DOMAIN not in hass.data: return False db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index f6c9a536a235ea..fe3bd2b098781a 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -4,21 +4,31 @@ from collections.abc import Callable, Coroutine from typing import Any -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + HassJob, + HomeAssistant, + callback, +) @callback -def async_at_start( +def _async_at_core_state( hass: HomeAssistant, at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], + event_type: str, + check_state: Callable[[HomeAssistant], bool], ) -> CALLBACK_TYPE: - """Execute something when Home Assistant is started. + """Execute a job at_start_cb when Home Assistant has the wanted state. - Will execute it now if Home Assistant is already started. + The job is executed immediately if Home Assistant is in the wanted state. + Will wait for event specified by event_type if it isn't. """ at_start_job = HassJob(at_start_cb) - if hass.is_running: + if check_state(hass): hass.async_run_hass_job(at_start_job, hass) return lambda: None @@ -36,5 +46,43 @@ def cancel() -> None: if unsub: unsub() - unsub = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _matched_event) + unsub = hass.bus.async_listen_once(event_type, _matched_event) return cancel + + +@callback +def async_at_start( + hass: HomeAssistant, + at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], +) -> CALLBACK_TYPE: + """Execute a job at_start_cb when Home Assistant is starting. + + The job is executed immediately if Home Assistant is already starting or started. + Will wait for EVENT_HOMEASSISTANT_START if it isn't. + """ + + def _is_running(hass: HomeAssistant) -> bool: + return hass.is_running + + return _async_at_core_state( + hass, at_start_cb, EVENT_HOMEASSISTANT_START, _is_running + ) + + +@callback +def async_at_started( + hass: HomeAssistant, + at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], +) -> CALLBACK_TYPE: + """Execute a job at_start_cb when Home Assistant has started. + + The job is executed immediately if Home Assistant is already started. + Will wait for EVENT_HOMEASSISTANT_STARTED if it isn't. + """ + + def _is_started(hass: HomeAssistant) -> bool: + return hass.state == CoreState.running + + return _async_at_core_state( + hass, at_start_cb, EVENT_HOMEASSISTANT_STARTED, _is_started + ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5a4d631a8c8bc1..dfab80e5223c71 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -25,7 +25,7 @@ from awesomeversion import AwesomeVersion import jinja2 -from jinja2 import pass_context, pass_environment +from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace import voluptuous as vol @@ -1063,6 +1063,14 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: ] +def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: + """Get an config entry ID from an entity ID.""" + entity_reg = entity_registry.async_get(hass) + if entity := entity_reg.async_get(entity_id): + return entity.config_entry_id + return None + + def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: """Get a device ID from an entity ID or device name.""" entity_reg = entity_registry.async_get(hass) @@ -1649,7 +1657,7 @@ def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any: return pass_environment(wrapper) -def average(*args: Any) -> float: +def average(*args: Any, default: Any = _SENTINEL) -> Any: """ Filter and function to calculate the arithmetic mean of an iterable or of two or more arguments. @@ -1658,13 +1666,23 @@ def average(*args: Any) -> float: if len(args) == 0: raise TypeError("average expected at least 1 argument, got 0") - if len(args) == 1: - if isinstance(args[0], Iterable): - return statistics.fmean(args[0]) - + # If first argument is iterable and more then 1 argument provided but not a named default, + # then use 2nd argument as default. + if isinstance(args[0], Iterable): + average_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + average_list = args - return statistics.fmean(args) + try: + return statistics.fmean(average_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("average", args) + return default def forgiving_float(value, default=_SENTINEL): @@ -2072,6 +2090,9 @@ def wrapper(*args, **kwargs): self.globals["device_attr"] = hassfunction(device_attr) self.globals["is_device_attr"] = hassfunction(is_device_attr) + self.globals["config_entry_id"] = hassfunction(config_entry_id) + self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) @@ -2132,9 +2153,13 @@ def warn_unsupported(*args, **kwargs): self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) self.globals["is_state"] = hassfunction(is_state) + self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) + self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) self.globals["state_attr"] = hassfunction(state_attr) + self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) + self.filters["states"] = self.globals["states"] self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index f2a7948f4f521c..d43eecda778f6d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -130,7 +130,9 @@ class Manifest(TypedDict, total=False): name: str disabled: str domain: str - integration_type: Literal["entity", "integration", "hardware", "helper", "system"] + integration_type: Literal[ + "entity", "device", "hardware", "helper", "hub", "service", "system" + ] dependencies: list[str] after_dependencies: list[str] requirements: list[str] @@ -150,7 +152,6 @@ class Manifest(TypedDict, total=False): version: str codeowners: list[str] loggers: list[str] - supported_brands: dict[str, str] def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest: @@ -224,7 +225,7 @@ async def async_get_custom_components( async def async_get_config_flows( hass: HomeAssistant, - type_filter: Literal["helper", "integration"] | None = None, + type_filter: Literal["device", "helper", "hub", "service"] | None = None, ) -> set[str]: """Return cached list of config flows.""" # pylint: disable=import-outside-toplevel @@ -263,7 +264,6 @@ async def async_get_integration_descriptions( custom_integrations = await async_get_custom_components(hass) custom_flows: dict[str, Any] = { "integration": {}, - "hardware": {}, "helper": {}, } @@ -272,19 +272,25 @@ async def async_get_integration_descriptions( if integration.integration_type in ("entity", "system"): continue - for integration_type in ("integration", "hardware", "helper"): + for integration_type in ("integration", "helper"): if integration.domain not in core_flows[integration_type]: continue del core_flows[integration_type][integration.domain] if integration.domain in core_flows["translated_name"]: core_flows["translated_name"].remove(integration.domain) + if integration.integration_type == "helper": + integration_key: str = integration.integration_type + else: + integration_key = "integration" + metadata = { "config_flow": integration.config_flow, + "integration_type": integration.integration_type, "iot_class": integration.iot_class, "name": integration.name, } - custom_flows[integration.integration_type][integration.domain] = metadata + custom_flows[integration_key][integration.domain] = metadata return {"core": core_flows, "custom": custom_flows} @@ -599,9 +605,9 @@ def iot_class(self) -> str | None: @property def integration_type( self, - ) -> Literal["entity", "integration", "hardware", "helper", "system"]: + ) -> Literal["entity", "device", "hardware", "helper", "hub", "service", "system"]: """Return the integration type.""" - return self.manifest.get("integration_type", "integration") + return self.manifest.get("integration_type", "hub") @property def mqtt(self) -> list[str] | None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2793a383050606..39158d63d550fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,32 +4,32 @@ aiodiscover==1.4.13 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 async_timeout==4.0.2 atomicwrites-homeassistant==1.4.1 attrs==21.2.0 awesomeversion==22.9.0 bcrypt==3.1.7 -bleak-retry-connector==2.1.3 -bleak==0.18.1 +bleak-retry-connector==2.8.2 +bleak==0.19.1 bluetooth-adapters==0.6.0 bluetooth-auto-recovery==0.3.6 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==38.0.1 -dbus-fast==1.24.0 +dbus-fast==1.61.1 fnvhash==0.1.0 hass-nabucasa==0.56.0 -home-assistant-bluetooth==1.3.0 -home-assistant-frontend==20221010.0 +home-assistant-bluetooth==1.6.0 +home-assistant-frontend==20221102.1 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.7.11 +orjson==3.8.1 paho-mqtt==1.6.1 pillow==9.2.0 -pip>=21.0,<22.3 +pip>=21.0,<22.4 psutil-home-assistant==0.0.1 pyserial==3.5 python-slugify==4.0.1 @@ -37,12 +37,12 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.1 scapy==2.4.5 -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 typing-extensions>=4.4.0,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.39.1 +zeroconf==0.39.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -114,9 +114,8 @@ multidict>=6.0.2 # https://github.com/home-assistant/core/pull/68176 authlib<1.0 -# Pin backoff for compatibility until most libraries have been updated -# https://github.com/home-assistant/core/pull/70817 -backoff<2.0 +# Version 2.0 added typing, prevent accidental fallbacks +backoff>=2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 494ee04546c988..3823c0e45bdbac 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -436,10 +436,12 @@ def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: def color_rgb_to_rgbww( - r: int, g: int, b: int, min_mireds: int, max_mireds: int + r: int, g: int, b: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int, int, int]: """Convert an rgb color to an rgbww representation.""" # Find the color temperature when both white channels have equal brightness + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) mired_range = max_mireds - min_mireds mired_midpoint = min_mireds + mired_range / 2 color_temp_kelvin = color_temperature_mired_to_kelvin(mired_midpoint) @@ -460,10 +462,12 @@ def color_rgb_to_rgbww( def color_rgbww_to_rgb( - r: int, g: int, b: int, cw: int, ww: int, min_mireds: int, max_mireds: int + r: int, g: int, b: int, cw: int, ww: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int]: """Convert an rgbww color to an rgb representation.""" # Calculate color temperature of the white channels + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) mired_range = max_mireds - min_mireds try: ct_ratio = ww / (cw + ww) @@ -530,9 +534,15 @@ def color_temperature_to_rgb( def color_temperature_to_rgbww( - temperature: int, brightness: int, min_mireds: int, max_mireds: int + temperature: int, brightness: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int, int, int]: - """Convert color temperature in mireds to rgbcw.""" + """Convert color temperature in kelvin to rgbcw. + + Returns a (r, g, b, cw, ww) tuple. + """ + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) + temperature = color_temperature_kelvin_to_mired(temperature) mired_range = max_mireds - min_mireds cold = ((max_mireds - temperature) / mired_range) * brightness warm = brightness - cold @@ -540,22 +550,33 @@ def color_temperature_to_rgbww( def rgbww_to_color_temperature( - rgbww: tuple[int, int, int, int, int], min_mireds: int, max_mireds: int + rgbww: tuple[int, int, int, int, int], min_kelvin: int, max_kelvin: int ) -> tuple[int, int]: - """Convert rgbcw to color temperature in mireds.""" + """Convert rgbcw to color temperature in kelvin. + + Returns a tuple (color_temperature, brightness). + """ _, _, _, cold, warm = rgbww - return while_levels_to_color_temperature(cold, warm, min_mireds, max_mireds) + return _white_levels_to_color_temperature(cold, warm, min_kelvin, max_kelvin) -def while_levels_to_color_temperature( - cold: int, warm: int, min_mireds: int, max_mireds: int +def _white_levels_to_color_temperature( + cold: int, warm: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int]: - """Convert whites to color temperature in mireds.""" + """Convert whites to color temperature in kelvin. + + Returns a tuple (color_temperature, brightness). + """ + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) brightness = warm / 255 + cold / 255 if brightness == 0: - return (max_mireds, 0) + # Return the warmest color if brightness is 0 + return (min_kelvin, 0) return round( - ((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds + color_temperature_mired_to_kelvin( + ((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds + ) ), min(255, round(brightness * 255)) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 80b322c1a14c8f..44e4403d689dd5 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -4,7 +4,9 @@ import bisect from contextlib import suppress import datetime as dt +import platform import re +import time from typing import Any import zoneinfo @@ -13,6 +15,7 @@ DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.timezone.utc DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.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 @@ -461,3 +464,26 @@ 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 __monotonic_time_coarse() -> float: + """Return a 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/ + """ + return time.clock_gettime(CLOCK_MONOTONIC_COARSE) + + +monotonic_time_coarse = time.monotonic +with suppress(Exception): + if ( + platform.system() == "Linux" + and abs(time.monotonic() - __monotonic_time_coarse()) < 1 + ): + monotonic_time_coarse = __monotonic_time_coarse diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 6d502ee6e6d860..aa2782e423f085 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -2,45 +2,6 @@ from __future__ import annotations from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MICROGRAMS, - MASS_MILLIGRAMS, - MASS_OUNCES, - MASS_POUNDS, - POWER_KILO_WATT, - POWER_WATT, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - SPEED_FEET_PER_SECOND, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_KNOTS, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, @@ -48,6 +9,14 @@ VOLUME_GALLONS, VOLUME_LITERS, VOLUME_MILLILITERS, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -71,6 +40,10 @@ _POUND_TO_G = 453.59237 _OUNCE_TO_G = _POUND_TO_G / 16 +# Pressure conversion constants +_STANDARD_GRAVITY = 9.80665 +_MERCURY_DENSITY = 13.5951 + # Volume conversion constants _L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ _ML_TO_CUBIC_METER = 0.001 * _L_TO_CUBIC_METER # 1 mL = 0.001 L @@ -121,26 +94,26 @@ class DistanceConverter(BaseUnitConverter): """Utility to convert distance values.""" UNIT_CLASS = "distance" - NORMALIZED_UNIT = LENGTH_METERS + NORMALIZED_UNIT = UnitOfLength.METERS _UNIT_CONVERSION: dict[str, float] = { - LENGTH_METERS: 1, - LENGTH_MILLIMETERS: 1 / _MM_TO_M, - LENGTH_CENTIMETERS: 1 / _CM_TO_M, - LENGTH_KILOMETERS: 1 / _KM_TO_M, - LENGTH_INCHES: 1 / _IN_TO_M, - LENGTH_FEET: 1 / _FOOT_TO_M, - LENGTH_YARD: 1 / _YARD_TO_M, - LENGTH_MILES: 1 / _MILE_TO_M, + UnitOfLength.METERS: 1, + UnitOfLength.MILLIMETERS: 1 / _MM_TO_M, + UnitOfLength.CENTIMETERS: 1 / _CM_TO_M, + UnitOfLength.KILOMETERS: 1 / _KM_TO_M, + UnitOfLength.INCHES: 1 / _IN_TO_M, + UnitOfLength.FEET: 1 / _FOOT_TO_M, + UnitOfLength.YARDS: 1 / _YARD_TO_M, + UnitOfLength.MILES: 1 / _MILE_TO_M, } VALID_UNITS = { - LENGTH_KILOMETERS, - LENGTH_MILES, - LENGTH_FEET, - LENGTH_METERS, - LENGTH_CENTIMETERS, - LENGTH_MILLIMETERS, - LENGTH_INCHES, - LENGTH_YARD, + UnitOfLength.KILOMETERS, + UnitOfLength.MILES, + UnitOfLength.FEET, + UnitOfLength.METERS, + UnitOfLength.CENTIMETERS, + UnitOfLength.MILLIMETERS, + UnitOfLength.INCHES, + UnitOfLength.YARDS, } @@ -148,16 +121,18 @@ class EnergyConverter(BaseUnitConverter): """Utility to convert energy values.""" UNIT_CLASS = "energy" - NORMALIZED_UNIT = ENERGY_KILO_WATT_HOUR + NORMALIZED_UNIT = UnitOfEnergy.KILO_WATT_HOUR _UNIT_CONVERSION: dict[str, float] = { - ENERGY_WATT_HOUR: 1 * 1000, - ENERGY_KILO_WATT_HOUR: 1, - ENERGY_MEGA_WATT_HOUR: 1 / 1000, + UnitOfEnergy.WATT_HOUR: 1 * 1000, + UnitOfEnergy.KILO_WATT_HOUR: 1, + UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1000, + UnitOfEnergy.GIGA_JOULE: 3.6 / 1000, } VALID_UNITS = { - ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.MEGA_WATT_HOUR, + UnitOfEnergy.GIGA_JOULE, } @@ -165,22 +140,22 @@ class MassConverter(BaseUnitConverter): """Utility to convert mass values.""" UNIT_CLASS = "mass" - NORMALIZED_UNIT = MASS_GRAMS + NORMALIZED_UNIT = UnitOfMass.GRAMS _UNIT_CONVERSION: dict[str, float] = { - MASS_MICROGRAMS: 1 * 1000 * 1000, - MASS_MILLIGRAMS: 1 * 1000, - MASS_GRAMS: 1, - MASS_KILOGRAMS: 1 / 1000, - MASS_OUNCES: 1 / _OUNCE_TO_G, - MASS_POUNDS: 1 / _POUND_TO_G, + UnitOfMass.MICROGRAMS: 1 * 1000 * 1000, + UnitOfMass.MILLIGRAMS: 1 * 1000, + UnitOfMass.GRAMS: 1, + UnitOfMass.KILOGRAMS: 1 / 1000, + UnitOfMass.OUNCES: 1 / _OUNCE_TO_G, + UnitOfMass.POUNDS: 1 / _POUND_TO_G, } VALID_UNITS = { - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MILLIGRAMS, - MASS_MICROGRAMS, - MASS_OUNCES, - MASS_POUNDS, + UnitOfMass.GRAMS, + UnitOfMass.KILOGRAMS, + UnitOfMass.MILLIGRAMS, + UnitOfMass.MICROGRAMS, + UnitOfMass.OUNCES, + UnitOfMass.POUNDS, } @@ -188,14 +163,14 @@ class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" UNIT_CLASS = "power" - NORMALIZED_UNIT = POWER_WATT + NORMALIZED_UNIT = UnitOfPower.WATT _UNIT_CONVERSION: dict[str, float] = { - POWER_WATT: 1, - POWER_KILO_WATT: 1 / 1000, + UnitOfPower.WATT: 1, + UnitOfPower.KILO_WATT: 1 / 1000, } VALID_UNITS = { - POWER_WATT, - POWER_KILO_WATT, + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, } @@ -203,28 +178,30 @@ class PressureConverter(BaseUnitConverter): """Utility to convert pressure values.""" UNIT_CLASS = "pressure" - NORMALIZED_UNIT = PRESSURE_PA + NORMALIZED_UNIT = UnitOfPressure.PA _UNIT_CONVERSION: dict[str, float] = { - PRESSURE_PA: 1, - PRESSURE_HPA: 1 / 100, - PRESSURE_KPA: 1 / 1000, - PRESSURE_BAR: 1 / 100000, - PRESSURE_CBAR: 1 / 1000, - PRESSURE_MBAR: 1 / 100, - PRESSURE_INHG: 1 / 3386.389, - PRESSURE_PSI: 1 / 6894.757, - PRESSURE_MMHG: 1 / 133.322, + UnitOfPressure.PA: 1, + UnitOfPressure.HPA: 1 / 100, + UnitOfPressure.KPA: 1 / 1000, + UnitOfPressure.BAR: 1 / 100000, + UnitOfPressure.CBAR: 1 / 1000, + UnitOfPressure.MBAR: 1 / 100, + UnitOfPressure.INHG: 1 + / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), + UnitOfPressure.PSI: 1 / 6894.757, + UnitOfPressure.MMHG: 1 + / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), } VALID_UNITS = { - PRESSURE_PA, - PRESSURE_HPA, - PRESSURE_KPA, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_MBAR, - PRESSURE_INHG, - PRESSURE_PSI, - PRESSURE_MMHG, + UnitOfPressure.PA, + UnitOfPressure.HPA, + UnitOfPressure.KPA, + UnitOfPressure.BAR, + UnitOfPressure.CBAR, + UnitOfPressure.MBAR, + UnitOfPressure.INHG, + UnitOfPressure.PSI, + UnitOfPressure.MMHG, } @@ -232,26 +209,28 @@ class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" UNIT_CLASS = "speed" - NORMALIZED_UNIT = SPEED_METERS_PER_SECOND + NORMALIZED_UNIT = UnitOfSpeed.METERS_PER_SECOND _UNIT_CONVERSION: dict[str, float] = { - SPEED_FEET_PER_SECOND: 1 / _FOOT_TO_M, - SPEED_INCHES_PER_DAY: _DAYS_TO_SECS / _IN_TO_M, - SPEED_INCHES_PER_HOUR: _HRS_TO_SECS / _IN_TO_M, - SPEED_KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M, - SPEED_KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, - SPEED_METERS_PER_SECOND: 1, - SPEED_MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, - SPEED_MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M, + UnitOfVolumetricFlux.INCHES_PER_DAY: _DAYS_TO_SECS / _IN_TO_M, + UnitOfVolumetricFlux.INCHES_PER_HOUR: _HRS_TO_SECS / _IN_TO_M, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: _HRS_TO_SECS / _MM_TO_M, + UnitOfSpeed.FEET_PER_SECOND: 1 / _FOOT_TO_M, + UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M, + UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, + UnitOfSpeed.METERS_PER_SECOND: 1, + UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, } VALID_UNITS = { - SPEED_FEET_PER_SECOND, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_KNOTS, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, + UnitOfVolumetricFlux.INCHES_PER_DAY, + UnitOfVolumetricFlux.INCHES_PER_HOUR, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, } @@ -259,16 +238,16 @@ class TemperatureConverter(BaseUnitConverter): """Utility to convert temperature values.""" UNIT_CLASS = "temperature" - NORMALIZED_UNIT = TEMP_CELSIUS + NORMALIZED_UNIT = UnitOfTemperature.CELSIUS VALID_UNITS = { - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.KELVIN, } _UNIT_CONVERSION = { - TEMP_CELSIUS: 1.0, - TEMP_FAHRENHEIT: 1.8, - TEMP_KELVIN: 1.0, + UnitOfTemperature.CELSIUS: 1.0, + UnitOfTemperature.FAHRENHEIT: 1.8, + UnitOfTemperature.KELVIN: 1.0, } @classmethod @@ -285,28 +264,28 @@ def convert(cls, value: float, from_unit: str, to_unit: str) -> float: if from_unit == to_unit: return value - if from_unit == TEMP_CELSIUS: - if to_unit == TEMP_FAHRENHEIT: + if from_unit == UnitOfTemperature.CELSIUS: + if to_unit == UnitOfTemperature.FAHRENHEIT: return cls._celsius_to_fahrenheit(value) - if to_unit == TEMP_KELVIN: + if to_unit == UnitOfTemperature.KELVIN: return cls._celsius_to_kelvin(value) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) - if from_unit == TEMP_FAHRENHEIT: - if to_unit == TEMP_CELSIUS: + if from_unit == UnitOfTemperature.FAHRENHEIT: + if to_unit == UnitOfTemperature.CELSIUS: return cls._fahrenheit_to_celsius(value) - if to_unit == TEMP_KELVIN: + if to_unit == UnitOfTemperature.KELVIN: return cls._celsius_to_kelvin(cls._fahrenheit_to_celsius(value)) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) - if from_unit == TEMP_KELVIN: - if to_unit == TEMP_CELSIUS: + if from_unit == UnitOfTemperature.KELVIN: + if to_unit == UnitOfTemperature.CELSIUS: return cls._kelvin_to_celsius(value) - if to_unit == TEMP_FAHRENHEIT: + if to_unit == UnitOfTemperature.FAHRENHEIT: return cls._celsius_to_fahrenheit(cls._kelvin_to_celsius(value)) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 56c588ed1c9a49..7e338f8f313cf3 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -2,35 +2,27 @@ from __future__ import annotations from numbers import Number +from typing import TYPE_CHECKING, Final + +import voluptuous as vol from homeassistant.const import ( ACCUMULATED_PRECIPITATION, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, LENGTH, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, MASS, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_OUNCES, - MASS_POUNDS, PRESSURE, - PRESSURE_PA, - PRESSURE_PSI, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, - VOLUME_GALLONS, - VOLUME_LITERS, WIND_SPEED, + UnitOfLength, + UnitOfMass, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, ) +from homeassistant.helpers.frame import report from .unit_conversion import ( DistanceConverter, @@ -40,9 +32,21 @@ VolumeConverter, ) +if TYPE_CHECKING: + from homeassistant.components.sensor import SensorDeviceClass + +_CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial" +_CONF_UNIT_SYSTEM_METRIC: Final = "metric" +_CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary" + LENGTH_UNITS = DistanceConverter.VALID_UNITS -MASS_UNITS: set[str] = {MASS_POUNDS, MASS_OUNCES, MASS_KILOGRAMS, MASS_GRAMS} +MASS_UNITS: set[str] = { + UnitOfMass.POUNDS, + UnitOfMass.OUNCES, + UnitOfMass.KILOGRAMS, + UnitOfMass.GRAMS, +} PRESSURE_UNITS = PressureConverter.VALID_UNITS @@ -50,29 +54,26 @@ WIND_SPEED_UNITS = SpeedConverter.VALID_UNITS -TEMPERATURE_UNITS: set[str] = {TEMP_FAHRENHEIT, TEMP_CELSIUS} +TEMPERATURE_UNITS: set[str] = {UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS} def _is_valid_unit(unit: str, unit_type: str) -> bool: """Check if the unit is valid for it's type.""" if unit_type == LENGTH: - units = LENGTH_UNITS - elif unit_type == ACCUMULATED_PRECIPITATION: - units = LENGTH_UNITS - elif unit_type == WIND_SPEED: - units = WIND_SPEED_UNITS - elif unit_type == TEMPERATURE: - units = TEMPERATURE_UNITS - elif unit_type == MASS: - units = MASS_UNITS - elif unit_type == VOLUME: - units = VOLUME_UNITS - elif unit_type == PRESSURE: - units = PRESSURE_UNITS - else: - return False - - return unit in units + return unit in LENGTH_UNITS + if unit_type == ACCUMULATED_PRECIPITATION: + return unit in LENGTH_UNITS + if unit_type == WIND_SPEED: + return unit in WIND_SPEED_UNITS + if unit_type == TEMPERATURE: + return unit in TEMPERATURE_UNITS + if unit_type == MASS: + return unit in MASS_UNITS + if unit_type == VOLUME: + return unit in VOLUME_UNITS + if unit_type == PRESSURE: + return unit in PRESSURE_UNITS + return False class UnitSystem: @@ -81,13 +82,15 @@ class UnitSystem: def __init__( self, name: str, - temperature: str, + *, + accumulated_precipitation: str, + conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str], length: str, - wind_speed: str, - volume: str, mass: str, pressure: str, - accumulated_precipitation: str, + temperature: str, + volume: str, + wind_speed: str, ) -> None: """Initialize the unit system object.""" errors: str = ", ".join( @@ -107,7 +110,7 @@ def __init__( if errors: raise ValueError(errors) - self.name = name + self._name = name self.accumulated_precipitation_unit = accumulated_precipitation self.temperature_unit = temperature self.length_unit = length @@ -115,11 +118,32 @@ def __init__( self.pressure_unit = pressure self.volume_unit = volume self.wind_speed_unit = wind_speed + self._conversions = conversions + + @property + def name(self) -> str: + """Return the name of the unit system.""" + report( + "accesses the `name` property of the unit system. " + "This is deprecated and will stop working in Home Assistant 2023.1. " + "Please adjust to use instance check instead.", + error_if_core=False, + ) + if self is IMPERIAL_SYSTEM: + # kept for compatibility reasons, with associated warning above + return _CONF_UNIT_SYSTEM_IMPERIAL + return self._name @property def is_metric(self) -> bool: """Determine if this is the metric unit system.""" - return self.name == CONF_UNIT_SYSTEM_METRIC + report( + "accesses the `is_metric` property of the unit system. " + "This is deprecated and will stop working in Home Assistant 2023.1. " + "Please adjust to use instance check instead.", + error_if_core=False, + ) + return self is METRIC_SYSTEM def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" @@ -188,25 +212,98 @@ def as_dict(self) -> dict[str, str]: WIND_SPEED: self.wind_speed_unit, } + def get_converted_unit( + self, + device_class: SensorDeviceClass | str | None, + original_unit: str | None, + ) -> str | None: + """Return converted unit given a device class or an original unit.""" + return self._conversions.get((device_class, original_unit)) + + +def get_unit_system(key: str) -> UnitSystem: + """Get unit system based on key.""" + if key == _CONF_UNIT_SYSTEM_US_CUSTOMARY: + return US_CUSTOMARY_SYSTEM + if key == _CONF_UNIT_SYSTEM_METRIC: + return METRIC_SYSTEM + raise ValueError(f"`{key}` is not a valid unit system key") + + +def _deprecated_unit_system(value: str) -> str: + """Convert deprecated unit system.""" + + if value == _CONF_UNIT_SYSTEM_IMPERIAL: + # need to add warning in 2023.1 + return _CONF_UNIT_SYSTEM_US_CUSTOMARY + return value + + +validate_unit_system = vol.All( + vol.Lower, + _deprecated_unit_system, + vol.Any(_CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_US_CUSTOMARY), +) METRIC_SYSTEM = UnitSystem( - CONF_UNIT_SYSTEM_METRIC, - TEMP_CELSIUS, - LENGTH_KILOMETERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + _CONF_UNIT_SYSTEM_METRIC, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={ + # Convert non-metric distances + ("distance", UnitOfLength.FEET): UnitOfLength.METERS, + ("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS, + ("distance", UnitOfLength.MILES): UnitOfLength.KILOMETERS, + ("distance", UnitOfLength.YARDS): UnitOfLength.METERS, + # Convert non-metric volumes of gas meters + ("gas", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + # Convert non-metric speeds except knots to km/h + ("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR, + ("speed", UnitOfSpeed.MILES_PER_HOUR): UnitOfSpeed.KILOMETERS_PER_HOUR, + # Convert non-metric volumes + ("volume", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + ("volume", UnitOfVolume.FLUID_OUNCES): UnitOfVolume.MILLILITERS, + ("volume", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert non-metric volumes of water meters + ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + }, + length=UnitOfLength.KILOMETERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) -IMPERIAL_SYSTEM = UnitSystem( - CONF_UNIT_SYSTEM_IMPERIAL, - TEMP_FAHRENHEIT, - LENGTH_MILES, - SPEED_MILES_PER_HOUR, - VOLUME_GALLONS, - MASS_POUNDS, - PRESSURE_PSI, - LENGTH_INCHES, +US_CUSTOMARY_SYSTEM = UnitSystem( + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + accumulated_precipitation=UnitOfLength.INCHES, + conversions={ + # Convert non-USCS distances + ("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, + ("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES, + ("distance", UnitOfLength.METERS): UnitOfLength.FEET, + ("distance", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, + # Convert non-USCS volumes of gas meters + ("gas", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + # Convert non-USCS speeds except knots to mph + ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, + ("speed", UnitOfSpeed.KILOMETERS_PER_HOUR): UnitOfSpeed.MILES_PER_HOUR, + # Convert non-USCS volumes + ("volume", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("volume", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + ("volume", UnitOfVolume.MILLILITERS): UnitOfVolume.FLUID_OUNCES, + # Convert non-USCS volumes of water meters + ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + }, + length=UnitOfLength.MILES, + mass=UnitOfMass.POUNDS, + pressure=UnitOfPressure.PSI, + temperature=UnitOfTemperature.FAHRENHEIT, + volume=UnitOfVolume.GALLONS, + wind_speed=UnitOfSpeed.MILES_PER_HOUR, ) + +IMPERIAL_SYSTEM = US_CUSTOMARY_SYSTEM +"""IMPERIAL_SYSTEM is deprecated. Please use US_CUSTOMARY_SYSTEM instead.""" diff --git a/mypy.ini b/mypy.ini index 04986db451cd24..cd6bc14169d0d1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -322,6 +322,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aqualogic.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -392,6 +402,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bayesian.*] +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.binary_sensor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -402,6 +422,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blockchain.*] +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.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -522,6 +552,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.clickatell.*] +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.clicksend.*] +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.cpuspeed.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -912,6 +962,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_sheets.*] +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.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1352,6 +1412,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lidarr.*] +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.lifx.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1832,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.radarr.*] +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.recollect_waste.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2052,6 +2132,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.skybell.*] +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.slack.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2082,6 +2172,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.snooz.*] +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.sonarr.*] +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.ssdp.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2243,6 +2353,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tibber.*] +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.tile.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2353,6 +2473,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.unifi.update] +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.unifiprotect.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2544,6 +2674,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wled.*] +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.worldclock.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 45deecfc02ecbe..678773abcb9a3d 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -267,6 +267,10 @@ class ObsoleteImportMatch: reason="replaced by EntityCategory enum", constant=re.compile(r"^(ENTITY_CATEGORY_(\w*))|(ENTITY_CATEGORIES)$"), ), + ObsoleteImportMatch( + reason="replaced by local constants", + constant=re.compile(r"^(CONF_UNIT_SYSTEM_(\w*))$"), + ), ], "homeassistant.core": [ ObsoleteImportMatch( @@ -292,6 +296,12 @@ class ObsoleteImportMatch: constant=re.compile(r"^(distance|pressure|speed|temperature|volume)$"), ), ], + "homeassistant.util.unit_system": [ + ObsoleteImportMatch( + reason="replaced by US_CUSTOMARY_SYSTEM", + constant=re.compile(r"^IMPERIAL_SYSTEM$"), + ), + ], } diff --git a/pyproject.toml b/pyproject.toml index 5ea26ef1976d83..b41ac861aca3ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.10.5" +version = "2022.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -36,15 +36,15 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.23.0", - "home-assistant-bluetooth==1.3.0", + "home-assistant-bluetooth==1.6.0", "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.5.0", # PyJWT has loose dependency. We want the latest one. "cryptography==38.0.1", - "orjson==3.7.11", - "pip>=21.0,<22.3", + "orjson==3.8.1", + "pip>=21.0,<22.4", "python-slugify==4.0.1", "pyyaml==6.0", "requests==2.28.1", diff --git a/requirements.txt b/requirements.txt index 0dfc353823ad81..962a9d59dc6d27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,14 +11,14 @@ bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 httpx==0.23.0 -home-assistant-bluetooth==1.3.0 +home-assistant-bluetooth==1.6.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.5.0 cryptography==38.0.1 -orjson==3.7.11 -pip>=21.0,<22.3 +orjson==3.8.1 +pip>=21.0,<22.4 python-slugify==4.0.1 pyyaml==6.0 requests==2.28.1 diff --git a/requirements_all.txt b/requirements_all.txt index c869fcfa433db5..bb2e4308e48e19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ AEMET-OpenData==0.2.1 AIOAladdinConnect==0.1.46 # homeassistant.components.adax -Adax-local==0.1.4 +Adax-local==0.1.5 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -37,7 +37,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.19.15 +PySwitchbot==0.20.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -153,7 +153,7 @@ aioecowitt==2022.09.3 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==11.1.1 +aioesphomeapi==11.4.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -162,7 +162,7 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github -aiogithubapi==22.2.4 +aiogithubapi==22.10.1 # homeassistant.components.guardian aioguardian==2022.07.0 @@ -171,7 +171,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.0.2 +aiohomekit==2.2.13 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -190,10 +190,13 @@ aiokafka==0.7.2 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.8.5 +aiolifx==0.8.6 # homeassistant.components.lifx -aiolifx_effects==0.2.2 +aiolifx_effects==0.3.0 + +# homeassistant.components.lifx +aiolifx_themes==0.2.0 # homeassistant.components.lookin aiolookin==0.1.1 @@ -226,7 +229,7 @@ aioopenexchangerates==0.4.0 aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.2 +aiopvapi==2.0.3 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 @@ -234,7 +237,7 @@ aiopvpc==3.0.0 # homeassistant.components.lidarr # homeassistant.components.radarr # homeassistant.components.sonarr -aiopyarr==22.9.0 +aiopyarr==22.10.0 # homeassistant.components.qnap_qsw aioqsw==0.2.2 @@ -252,7 +255,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.2 +aioshelly==4.1.2 # homeassistant.components.skybell aioskybell==22.7.0 @@ -264,7 +267,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.0.0 +aioswitcher==3.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -273,7 +276,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==41 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -290,6 +293,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 @@ -309,7 +315,7 @@ ambiclimate==0.2.1 amcrest==1.9.7 # homeassistant.components.androidtv -androidtv[async]==0.0.67 +androidtv[async]==0.0.69 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -321,7 +327,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.0.0 +apprise==1.1.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -347,7 +353,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -407,13 +413,13 @@ bimmer_connected==0.10.4 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.1.3 +bleak-retry-connector==2.8.2 # homeassistant.components.bluetooth -bleak==0.18.1 +bleak==0.19.1 # homeassistant.components.blebox -blebox_uniapi==2.0.2 +blebox_uniapi==2.1.3 # homeassistant.components.blink blinkpy==0.19.2 @@ -459,9 +465,6 @@ brottsplatskartan==0.0.1 # homeassistant.components.brunt brunt==1.2.0 -# homeassistant.components.bsblan -bsblan==0.5.0 - # homeassistant.components.bluetooth_tracker bt_proximity==0.2.1 @@ -537,7 +540,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.24.0 +dbus-fast==1.61.1 # homeassistant.components.debugpy debugpy==1.6.3 @@ -557,10 +560,10 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.lametric -demetriek==0.2.4 +demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.11 +denonavr==0.10.12 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -684,7 +687,7 @@ fivem-api==0.1.2 fixerio==1.0.0a0 # homeassistant.components.fjaraskupan -fjaraskupan==2.0.0 +fjaraskupan==2.2.0 # homeassistant.components.flipr flipr-api==1.4.2 @@ -706,7 +709,7 @@ forecast_solar==2.2.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==0.0.10 +freebox-api==1.0.1 # homeassistant.components.free_mobile freesms==0.2.0 @@ -722,7 +725,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.10.0 +gcal-sync==2.2.3 # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -765,10 +768,10 @@ goalzero==0.2.1 goodwe==0.2.18 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.11.0 +google-cloud-pubsub==2.13.10 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.12.1 +google-cloud-texttospeech==2.12.3 # homeassistant.components.nest google-nest-sdm==2.0.0 @@ -801,7 +804,7 @@ greenwavereality==0.5.1 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.2.2 +growattServer==1.2.3 # homeassistant.components.google_sheets gspread==5.5.0 @@ -817,7 +820,7 @@ ha-HAP-python==4.5.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b5 +ha-av==10.0.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -865,7 +868,7 @@ hole==0.7.0 holidays==0.16 # homeassistant.components.frontend -home-assistant-frontend==20221010.0 +home-assistant-frontend==20221102.1 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -883,7 +886,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.1 +huawei-lte-api==1.6.3 # homeassistant.components.hydrawise hydrawiser==0.2 @@ -895,10 +898,10 @@ hyperion-py==0.7.5 iammeter==0.1.7 # homeassistant.components.iaqualink -iaqualink==0.4.1 +iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.3 +ibeacon_ble==1.0.1 # homeassistant.components.watson_tts ibm-watson==5.2.2 @@ -946,7 +949,7 @@ iperf3==0.1.11 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.8.1 +jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest jsonpath==0.82 @@ -985,7 +988,7 @@ lakeside==0.12 laundrify_aio==1.1.2 # homeassistant.components.led_ble -led-ble==0.10.1 +led-ble==1.0.0 # homeassistant.components.foscam libpyfoscam==1.0 @@ -1027,7 +1030,7 @@ london-tube-status==0.5 luftdaten==0.7.2 # homeassistant.components.lupusec -lupupy==0.0.24 +lupupy==0.1.9 # homeassistant.components.lw12wifi lw12==0.9.2 @@ -1066,7 +1069,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.0.2 +meteofrance-api==1.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1099,7 +1102,7 @@ motioneye-client==0.3.12 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.45.1 +mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -1132,7 +1135,7 @@ nettigo-air-monitor==1.4.2 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.4 +nexia==2.0.5 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 @@ -1234,6 +1237,9 @@ openwrt-luci-rpc==1.1.11 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 +# homeassistant.components.oralb +oralb-ble==0.10.0 + # homeassistant.components.oru oru==0.1.11 @@ -1306,7 +1312,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.21.4 +plugwise==0.25.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1339,7 +1345,7 @@ proxmoxer==1.3.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.2 +psutil==5.9.3 # homeassistant.components.pulseaudio_loopback pulsectl==20.2.4 @@ -1403,7 +1409,7 @@ pyRFXtrx==0.30.0 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.25.2 +pyTibber==0.25.6 # homeassistant.components.dlink pyW215==0.7.0 @@ -1436,7 +1442,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.1.1 +pyatmo==7.3.0 # homeassistant.components.atome pyatome==0.1.1 @@ -1472,7 +1478,7 @@ pycarwings2==2.13 pycfdns==1.2.2 # homeassistant.components.channels -pychannels==1.0.0 +pychannels==1.2.3 # homeassistant.components.cast pychromecast==12.1.4 @@ -1499,13 +1505,13 @@ pycsspeechtts==1.0.4 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.7.2 +pydaikin==2.8.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==104 +pydeconz==105 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1532,7 +1538,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.3.0 +pyeight==0.3.2 # homeassistant.components.emby pyemby==1.8 @@ -1568,7 +1574,7 @@ pyflume==0.6.5 pyfnip==0.2 # homeassistant.components.forked_daapd -pyforked-daapd==0.1.11 +pyforked-daapd==0.1.14 # homeassistant.components.freedompro pyfreedompro==1.1.0 @@ -1625,7 +1631,7 @@ pyintesishome==1.8.0 pyipma==3.0.5 # homeassistant.components.ipp -pyipp==0.11.0 +pyipp==0.12.1 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1637,7 +1643,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.7 +pyisy==3.0.8 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -1676,16 +1682,16 @@ pylaunches==1.3.0 pylgnetcast==0.3.7 # homeassistant.components.forked_daapd -pylibrespot-java==0.1.0 +pylibrespot-java==0.1.1 # homeassistant.components.litejet pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2022.9.6 +pylitterbot==2022.10.2 # homeassistant.components.lutron_caseta -pylutron-caseta==0.15.2 +pylutron-caseta==0.17.1 # homeassistant.components.lutron pylutron==0.2.8 @@ -1739,7 +1745,7 @@ pynetio==0.1.9.1 pynina==0.1.8 # homeassistant.components.nobo_hub -pynobo==1.4.0 +pynobo==1.6.0 # homeassistant.components.nuki pynuki==1.5.2 @@ -1760,7 +1766,7 @@ pynzbgetapi==0.2.0 pyobihai==1.3.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.8 +pyoctoprintapi==0.1.9 # homeassistant.components.ombi pyombi==0.1.10 @@ -1775,7 +1781,7 @@ pyopnsense==0.2.0 pyoppleio==1.0.5 # homeassistant.components.opentherm_gw -pyotgw==2.0.3 +pyotgw==2.1.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1783,7 +1789,7 @@ pyotgw==2.0.3 pyotp==2.7.0 # homeassistant.components.overkiz -pyoverkiz==1.5.5 +pyoverkiz==1.5.6 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1884,7 +1890,7 @@ pysignalclirestapi==0.3.18 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.6.12 +pysma==0.7.2 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1904,6 +1910,9 @@ pysml==0.0.8 # homeassistant.components.snmp pysnmplib==5.0.15 +# homeassistant.components.snooz +pysnooz==0.8.2 + # homeassistant.components.soma pysoma==0.0.10 @@ -1911,7 +1920,7 @@ pysoma==0.0.10 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.0 +pysqueezebox==0.6.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 @@ -1940,6 +1949,9 @@ pythinkingcleaner==0.0.3 # homeassistant.components.blockchain python-blockchain-api==0.0.2 +# homeassistant.components.bsblan +python-bsblan==0.5.5 + # homeassistant.components.clementine python-clementine-remote==1.0.1 @@ -2057,13 +2069,13 @@ pytradfri[async]==9.0.0 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.0.1 +pytrafikverket==0.2.1 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.2.0 +pyunifiprotect==4.3.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2114,7 +2126,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.7.0 +qingping-ble==0.8.2 # homeassistant.components.qnap qnapstats==0.4.0 @@ -2138,7 +2150,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.09.2 +regenmaschine==2022.10.0 # homeassistant.components.renault renault-api==0.1.11 @@ -2229,7 +2241,7 @@ sensorpro-ble==0.5.0 sensorpush-ble==1.5.2 # homeassistant.components.sentry -sentry-sdk==1.9.8 +sentry-sdk==1.10.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -2244,7 +2256,7 @@ shodan==1.28.0 simplehound==0.3 # homeassistant.components.simplepush -simplepush==1.1.4 +simplepush==2.1.1 # homeassistant.components.simplisafe simplisafe-python==2022.07.1 @@ -2265,10 +2277,10 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.1.3 +snapcast==2.3.0 # homeassistant.components.sonos -soco==0.28.0 +soco==0.28.1 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2299,7 +2311,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2329,7 +2341,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.5.0 +subarulink==0.6.1 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -2368,7 +2380,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.5.3 +temperusb==1.6.0 # homeassistant.components.nibe_heatpump tenacity==8.0.1 @@ -2443,7 +2455,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.4.3 +ultraheat-api==0.5.0 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -2472,7 +2484,7 @@ vallox-websocket-api==2.12.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.10.2 +velbus-aio==2022.10.4 # homeassistant.components.venstar venstarcolortouch==0.18 @@ -2548,7 +2560,7 @@ xboxapi==2.0.1 xiaomi-ble==0.10.0 # homeassistant.components.knx -xknx==1.1.0 +xknx==1.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2565,7 +2577,7 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.9.2 +yalexs-ble==1.9.5 # homeassistant.components.august yalexs==1.2.6 @@ -2585,14 +2597,17 @@ youless-api==0.16 # homeassistant.components.media_extractor youtube_dl==2021.12.17 +# homeassistant.components.zamg +zamg==0.1.1 + # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.39.1 +zeroconf==0.39.4 # homeassistant.components.zha -zha-quirks==0.0.83 +zha-quirks==0.0.84 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2607,13 +2622,13 @@ zigpy-deconz==0.19.0 zigpy-xbee==0.16.2 # homeassistant.components.zha -zigpy-zigate==0.10.2 +zigpy-zigate==0.10.3 # homeassistant.components.zha zigpy-znp==0.9.1 # homeassistant.components.zha -zigpy==0.51.3 +zigpy==0.51.5 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index 9eccb9abb68ee0..b15ceb3b0023ca 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.12.5 +astroid==2.12.12 codecov==2.1.12 coverage==6.4.4 -freezegun==1.2.1 +freezegun==1.2.2 mock-open==1.4.0 -mypy==0.981 +mypy==0.982 pre-commit==2.20.0 -pylint==2.15.0 +pylint==2.15.5 pipdeptree==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c03bd6ec6050f..a78ce61f213b46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.2.1 AIOAladdinConnect==0.1.46 # homeassistant.components.adax -Adax-local==0.1.4 +Adax-local==0.1.5 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -33,7 +33,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.19.15 +PySwitchbot==0.20.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -140,13 +140,13 @@ aioecowitt==2022.09.3 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==11.1.1 +aioesphomeapi==11.4.2 # homeassistant.components.flo aioflo==2021.11.0 # homeassistant.components.github -aiogithubapi==22.2.4 +aiogithubapi==22.10.1 # homeassistant.components.guardian aioguardian==2022.07.0 @@ -155,7 +155,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.0.2 +aiohomekit==2.2.13 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -168,10 +168,13 @@ aiohue==4.5.0 aiokafka==0.7.2 # homeassistant.components.lifx -aiolifx==0.8.5 +aiolifx==0.8.6 # homeassistant.components.lifx -aiolifx_effects==0.2.2 +aiolifx_effects==0.3.0 + +# homeassistant.components.lifx +aiolifx_themes==0.2.0 # homeassistant.components.lookin aiolookin==0.1.1 @@ -201,7 +204,7 @@ aioopenexchangerates==0.4.0 aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.2 +aiopvapi==2.0.3 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 @@ -209,7 +212,7 @@ aiopvpc==3.0.0 # homeassistant.components.lidarr # homeassistant.components.radarr # homeassistant.components.sonarr -aiopyarr==22.9.0 +aiopyarr==22.10.0 # homeassistant.components.qnap_qsw aioqsw==0.2.2 @@ -227,7 +230,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.2 +aioshelly==4.1.2 # homeassistant.components.skybell aioskybell==22.7.0 @@ -239,7 +242,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.0.0 +aioswitcher==3.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -248,7 +251,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==41 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -265,6 +268,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 @@ -278,7 +284,7 @@ amberelectric==1.0.4 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.67 +androidtv[async]==0.0.69 # homeassistant.components.anthemav anthemav==1.4.1 @@ -287,7 +293,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.0.0 +apprise==1.1.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -301,7 +307,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 @@ -331,13 +337,13 @@ bellows==0.34.2 bimmer_connected==0.10.4 # homeassistant.components.bluetooth -bleak-retry-connector==2.1.3 +bleak-retry-connector==2.8.2 # homeassistant.components.bluetooth -bleak==0.18.1 +bleak==0.19.1 # homeassistant.components.blebox -blebox_uniapi==2.0.2 +blebox_uniapi==2.1.3 # homeassistant.components.blink blinkpy==0.19.2 @@ -366,9 +372,6 @@ brother==2.0.0 # homeassistant.components.brunt brunt==1.2.0 -# homeassistant.components.bsblan -bsblan==0.5.0 - # homeassistant.components.bthome bthome-ble==1.2.2 @@ -417,7 +420,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.24.0 +dbus-fast==1.61.1 # homeassistant.components.debugpy debugpy==1.6.3 @@ -431,10 +434,10 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.lametric -demetriek==0.2.4 +demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.11 +denonavr==0.10.12 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -506,7 +509,7 @@ file-read-backwards==2.0.0 fivem-api==0.1.2 # homeassistant.components.fjaraskupan -fjaraskupan==2.0.0 +fjaraskupan==2.2.0 # homeassistant.components.flipr flipr-api==1.4.2 @@ -525,7 +528,7 @@ foobot_async==1.0.0 forecast_solar==2.2.0 # homeassistant.components.freebox -freebox-api==0.0.10 +freebox-api==1.0.1 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -538,7 +541,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.10.0 +gcal-sync==2.2.3 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -575,7 +578,7 @@ goalzero==0.2.1 goodwe==0.2.18 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.11.0 +google-cloud-pubsub==2.13.10 # homeassistant.components.nest google-nest-sdm==2.0.0 @@ -596,7 +599,7 @@ greeneye_monitor==3.0.3 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.2.2 +growattServer==1.2.3 # homeassistant.components.google_sheets gspread==5.5.0 @@ -609,7 +612,7 @@ ha-HAP-python==4.5.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b5 +ha-av==10.0.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -645,7 +648,7 @@ hole==0.7.0 holidays==0.16 # homeassistant.components.frontend -home-assistant-frontend==20221010.0 +home-assistant-frontend==20221102.1 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -660,16 +663,16 @@ homepluscontrol==0.0.5 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.1 +huawei-lte-api==1.6.3 # homeassistant.components.hyperion hyperion-py==0.7.5 # homeassistant.components.iaqualink -iaqualink==0.4.1 +iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.3 +ibeacon_ble==1.0.1 # homeassistant.components.ping icmplib==3.0 @@ -699,7 +702,7 @@ iotawattpy==0.1.0 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.8.1 +jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest jsonpath==0.82 @@ -726,7 +729,7 @@ lacrosse-view==0.0.9 laundrify_aio==1.1.2 # homeassistant.components.led_ble -led-ble==0.10.1 +led-ble==1.0.0 # homeassistant.components.foscam libpyfoscam==1.0 @@ -768,7 +771,7 @@ meater-python==0.0.8 melnor-bluetooth==0.0.20 # homeassistant.components.meteo_france -meteofrance-api==1.0.2 +meteofrance-api==1.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -801,7 +804,7 @@ motioneye-client==0.3.12 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.45.1 +mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -822,7 +825,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.4.2 # homeassistant.components.nexia -nexia==2.0.4 +nexia==2.0.5 # homeassistant.components.discord nextcord==2.0.0a8 @@ -879,6 +882,9 @@ open-meteo==0.2.1 # homeassistant.components.openerz openerz-api==0.1.0 +# homeassistant.components.oralb +oralb-ble==0.10.0 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 @@ -933,7 +939,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.21.4 +plugwise==0.25.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1003,7 +1009,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.0 # homeassistant.components.tibber -pyTibber==0.25.2 +pyTibber==0.25.6 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -1024,7 +1030,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.1.1 +pyatmo==7.3.0 # homeassistant.components.apple_tv pyatv==0.10.3 @@ -1057,10 +1063,10 @@ pycomfoconnect==0.4 pycoolmasternet-async==0.1.2 # homeassistant.components.daikin -pydaikin==2.7.2 +pydaikin==2.8.0 # homeassistant.components.deconz -pydeconz==104 +pydeconz==105 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1075,7 +1081,7 @@ pyeconet==0.1.15 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.3.0 +pyeight==0.3.2 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1099,7 +1105,7 @@ pyflic==2.0.3 pyflume==0.6.5 # homeassistant.components.forked_daapd -pyforked-daapd==0.1.11 +pyforked-daapd==0.1.14 # homeassistant.components.freedompro pyfreedompro==1.1.0 @@ -1141,7 +1147,7 @@ pyinsteon==1.2.0 pyipma==3.0.5 # homeassistant.components.ipp -pyipp==0.11.0 +pyipp==0.12.1 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1150,7 +1156,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.7 +pyisy==3.0.8 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1177,16 +1183,16 @@ pylast==4.2.1 pylaunches==1.3.0 # homeassistant.components.forked_daapd -pylibrespot-java==0.1.0 +pylibrespot-java==0.1.1 # homeassistant.components.litejet pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2022.9.6 +pylitterbot==2022.10.2 # homeassistant.components.lutron_caseta -pylutron-caseta==0.15.2 +pylutron-caseta==0.17.1 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1225,7 +1231,7 @@ pynetgear==0.10.8 pynina==0.1.8 # homeassistant.components.nobo_hub -pynobo==1.4.0 +pynobo==1.6.0 # homeassistant.components.nuki pynuki==1.5.2 @@ -1243,7 +1249,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.octoprint -pyoctoprintapi==0.1.8 +pyoctoprintapi==0.1.9 # homeassistant.components.openuv pyopenuv==2022.04.0 @@ -1252,7 +1258,7 @@ pyopenuv==2022.04.0 pyopnsense==0.2.0 # homeassistant.components.opentherm_gw -pyotgw==2.0.3 +pyotgw==2.1.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1260,7 +1266,7 @@ pyotgw==2.0.3 pyotp==2.7.0 # homeassistant.components.overkiz -pyoverkiz==1.5.5 +pyoverkiz==1.5.6 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1325,7 +1331,7 @@ pysiaalarm==3.0.2 pysignalclirestapi==0.3.18 # homeassistant.components.sma -pysma==0.6.12 +pysma==0.7.2 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1339,6 +1345,9 @@ pysmartthings==0.7.6 # homeassistant.components.snmp pysnmplib==5.0.15 +# homeassistant.components.snooz +pysnooz==0.8.2 + # homeassistant.components.soma pysoma==0.0.10 @@ -1346,7 +1355,7 @@ pysoma==0.0.10 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.0 +pysqueezebox==0.6.1 # homeassistant.components.switchbee pyswitchbee==1.5.5 @@ -1360,6 +1369,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==21.11.0 +# homeassistant.components.bsblan +python-bsblan==0.5.5 + # homeassistant.components.ecobee python-ecobee-api==0.2.14 @@ -1420,13 +1432,13 @@ pytradfri[async]==9.0.0 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.0.1 +pytrafikverket==0.2.1 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.2.0 +pyunifiprotect==4.3.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1462,7 +1474,7 @@ pyws66i==1.1 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.7.0 +qingping-ble==0.8.2 # homeassistant.components.rachio rachiopy==1.0.3 @@ -1474,7 +1486,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rainmachine -regenmaschine==2022.09.2 +regenmaschine==2022.10.0 # homeassistant.components.renault renault-api==0.1.11 @@ -1532,7 +1544,7 @@ sensorpro-ble==0.5.0 sensorpush-ble==1.5.2 # homeassistant.components.sentry -sentry-sdk==1.9.8 +sentry-sdk==1.10.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -1541,7 +1553,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplepush -simplepush==1.1.4 +simplepush==2.1.1 # homeassistant.components.simplisafe simplisafe-python==2022.07.1 @@ -1556,7 +1568,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.sonos -soco==0.28.0 +soco==0.28.1 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1584,7 +1596,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1608,7 +1620,7 @@ stookalert==0.1.4 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.5.0 +subarulink==0.6.1 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -1680,7 +1692,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.4.3 +ultraheat-api==0.5.0 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -1706,7 +1718,7 @@ vallox-websocket-api==2.12.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.10.2 +velbus-aio==2022.10.4 # homeassistant.components.venstar venstarcolortouch==0.18 @@ -1761,7 +1773,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.10.0 # homeassistant.components.knx -xknx==1.1.0 +xknx==1.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1775,7 +1787,7 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.9.2 +yalexs-ble==1.9.5 # homeassistant.components.august yalexs==1.2.6 @@ -1789,11 +1801,14 @@ yolink-api==0.1.0 # homeassistant.components.youless youless-api==0.16 +# homeassistant.components.zamg +zamg==0.1.1 + # homeassistant.components.zeroconf -zeroconf==0.39.1 +zeroconf==0.39.4 # homeassistant.components.zha -zha-quirks==0.0.83 +zha-quirks==0.0.84 # homeassistant.components.zha zigpy-deconz==0.19.0 @@ -1802,13 +1817,13 @@ zigpy-deconz==0.19.0 zigpy-xbee==0.16.2 # homeassistant.components.zha -zigpy-zigate==0.10.2 +zigpy-zigate==0.10.3 # homeassistant.components.zha zigpy-znp==0.9.1 # homeassistant.components.zha -zigpy==0.51.3 +zigpy==0.51.5 # homeassistant.components.zwave_js zwave-js-server-python==0.43.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1bb2e0a70e492f..ec6edeeea66d8e 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==22.8.0 +black==22.10.0 codespell==2.1.0 flake8-comprehensions==3.10.0 flake8-docstrings==1.6.0 @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.38.0 -yamllint==1.27.1 +pyupgrade==3.1.0 +yamllint==1.28.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3c2f92cf649303..bbc970f91785e8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -124,9 +124,8 @@ # https://github.com/home-assistant/core/pull/68176 authlib<1.0 -# Pin backoff for compatibility until most libraries have been updated -# https://github.com/home-assistant/core/pull/70817 -backoff<2.0 +# Version 2.0 added typing, prevent accidental fallbacks +backoff>=2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 7fb6ad2d8d01a8..2725296911882a 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -20,7 +20,6 @@ requirements, services, ssdp, - supported_brands, translations, usb, zeroconf, @@ -39,7 +38,6 @@ requirements, services, ssdp, - supported_brands, translations, usb, zeroconf, diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index 5511bc8a518dfc..0cc580121627ca 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -25,6 +25,8 @@ # Other code /homeassistant/scripts/check_config.py @kellerza +/homeassistant/const.py @epenet +/homeassistant/util/ @epenet # Integrations """.strip() @@ -47,7 +49,10 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if ( + not integration.manifest + or integration.manifest.get("integration_type") == "virtual" + ): continue codeowners = integration.manifest["codeowners"] diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 81cc5fae829596..9cebb37d3717bb 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -86,7 +86,10 @@ def _generate_and_validate(integrations: dict[str, Integration], config: Config) _validate_integration(config, integration) - domains[integration.integration_type].append(domain) + if integration.integration_type == "helper": + domains["helper"].append(domain) + else: + domains["integration"].append(domain) return black.format_str(BASE.format(to_string(domains)), mode=black.Mode()) @@ -101,11 +104,23 @@ def _populate_brand_integrations( brand_metadata.setdefault("integrations", {}) for domain in sub_integrations: integration = integrations.get(domain) - if not integration or integration.integration_type in ("entity", "system"): + if not integration or integration.integration_type in ( + "entity", + "hardware", + "system", + ): continue - metadata = {} - metadata["config_flow"] = integration.config_flow - metadata["iot_class"] = integration.iot_class + metadata = { + "integration_type": integration.integration_type, + } + if integration.config_flow: + metadata["config_flow"] = integration.config_flow + if integration.iot_class: + metadata["iot_class"] = integration.iot_class + if integration.supported_by: + metadata["supported_by"] = integration.supported_by + if integration.iot_standards: + metadata["iot_standards"] = integration.iot_standards if integration.translated_name: integration_data["translated_name"].add(domain) else: @@ -120,7 +135,6 @@ def _generate_integrations( result = { "integration": {}, - "hardware": {}, "helper": {}, "translated_name": set(), } @@ -165,15 +179,30 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system"): + if integration.integration_type in ("entity", "system", "hardware"): continue - metadata["config_flow"] = integration.config_flow - metadata["iot_class"] = integration.iot_class + if integration.translated_name: result["translated_name"].add(domain) else: metadata["name"] = integration.name - result[integration.integration_type][domain] = metadata + + metadata["integration_type"] = integration.integration_type + + if integration.integration_type == "virtual": + if integration.supported_by: + metadata["supported_by"] = integration.supported_by + if integration.iot_standards: + metadata["iot_standards"] = integration.iot_standards + else: + metadata["config_flow"] = integration.config_flow + if integration.iot_class: + metadata["iot_class"] = integration.iot_class + + if integration.integration_type == "helper": + result["helper"][domain] = metadata + else: + result["integration"][domain] = metadata return json.dumps( result | {"translated_name": sorted(result["translated_name"])}, indent=2 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index de227cbb59ca11..874fb069818285 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from urllib.parse import urlparse from awesomeversion import ( @@ -158,12 +159,20 @@ def verify_wildcard(value: str): return value -MANIFEST_SCHEMA = vol.Schema( +INTEGRATION_MANIFEST_SCHEMA = vol.Schema( { vol.Required("domain"): str, vol.Required("name"): str, - vol.Optional("integration_type"): vol.In( - ["entity", "hardware", "helper", "system"] + vol.Optional("integration_type", default="hub"): vol.In( + [ + "device", + "entity", + "hardware", + "helper", + "hub", + "service", + "system", + ] ), vol.Optional("config_flow"): bool, vol.Optional("mqtt"): [str], @@ -246,14 +255,32 @@ def verify_wildcard(value: str): vol.Optional("loggers"): [str], vol.Optional("disabled"): str, vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES), - vol.Optional("supported_brands"): vol.Schema({str: str}), } ) -CUSTOM_INTEGRATION_MANIFEST_SCHEMA = MANIFEST_SCHEMA.extend( +VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema( + { + vol.Required("domain"): str, + vol.Required("name"): str, + vol.Required("integration_type"): "virtual", + vol.Exclusive("iot_standards", "virtual_integration"): [ + vol.Any("homekit", "zigbee", "zwave") + ], + vol.Exclusive("supported_by", "virtual_integration"): str, + } +) + + +def manifest_schema(value: dict[str, Any]) -> vol.Schema: + """Validate integration manifest.""" + if value.get("integration_type") == "virtual": + return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value) + return INTEGRATION_MANIFEST_SCHEMA(value) + + +CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { vol.Optional("version"): vol.All(str, verify_version), - vol.Remove("supported_brands"): dict, } ) @@ -276,7 +303,7 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No try: if integration.core: - MANIFEST_SCHEMA(integration.manifest) + manifest_schema(integration.manifest) else: CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) except vol.Invalid as err: @@ -304,15 +331,19 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No if ( integration.manifest["domain"] not in NO_IOT_CLASS and "iot_class" not in integration.manifest + and integration.manifest.get("integration_type") != "virtual" ): integration.add_error("manifest", "Domain is missing an IoT Class") - for domain, _name in integration.manifest.get("supported_brands", {}).items(): - if (core_components_dir / domain).exists(): - integration.add_warning( - "manifest", - f"Supported brand domain {domain} collides with built-in core integration", - ) + if ( + integration.manifest.get("integration_type") == "virtual" + and (supported_by := integration.manifest.get("supported_by")) + and not (core_components_dir / supported_by).exists() + ): + integration.add_error( + "manifest", + "Virtual integration points to non-existing supported_by integration", + ) if not integration.core: validate_version(integration) diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 6a7ab64c14f36a..65d3b1144e8cc4 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -109,11 +109,12 @@ def load_dir(cls, path: pathlib.Path): continue init = fil / "__init__.py" - if not init.exists(): + manifest = fil / "manifest.json" + if not init.exists() and not manifest.exists(): print( - f"Warning: {init} missing, skipping directory. " - "If this is your development environment, " - "you can safely delete this folder." + f"Warning: {init} and manifest.json missing, " + "skipping directory. If this is your development " + "environment, you can safely delete this folder." ) continue @@ -170,20 +171,25 @@ def dependencies(self) -> list[str]: return self.manifest.get("dependencies", []) @property - def supported_brands(self) -> dict[str]: - """Return dict of supported brands.""" - return self.manifest.get("supported_brands", {}) + def supported_by(self) -> str: + """Return the integration supported by this virtual integration.""" + return self.manifest.get("supported_by", {}) @property def integration_type(self) -> str: """Get integration_type.""" - return self.manifest.get("integration_type", "integration") + return self.manifest.get("integration_type", "hub") @property def iot_class(self) -> str | None: """Return the integration IoT Class.""" return self.manifest.get("iot_class") + @property + def iot_standards(self) -> list[str]: + """Return the IoT standard supported by this virtual integration.""" + return self.manifest.get("iot_standards", []) + def add_error(self, *args: Any, **kwargs: Any) -> None: """Add an error.""" self.errors.append(Error(*args, **kwargs)) diff --git a/script/hassfest/supported_brands.py b/script/hassfest/supported_brands.py deleted file mode 100644 index 4ac2feb40324db..00000000000000 --- a/script/hassfest/supported_brands.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Generate supported_brands data.""" -from __future__ import annotations - -import black - -from .model import Config, Integration -from .serializer import to_string - -BASE = """ -\"\"\"Automatically generated by hassfest. - -To update, run python3 -m script.hassfest -\"\"\" - -HAS_SUPPORTED_BRANDS = {} -""".strip() - - -def generate_and_validate(integrations: dict[str, Integration], config: Config) -> str: - """Validate and generate supported_brands data.""" - - brands = [ - domain - for domain, integration in sorted(integrations.items()) - if integration.supported_brands - ] - - return black.format_str(BASE.format(to_string(brands)), mode=black.Mode()) - - -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Validate supported_brands data.""" - supported_brands_path = config.root / "homeassistant/generated/supported_brands.py" - config.cache["supported_brands"] = content = generate_and_validate( - integrations, config - ) - - if config.specific_integrations: - return - - if supported_brands_path.read_text(encoding="utf-8") != content: - config.add_error( - "supported_brands", - "File supported_brands.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - - -def generate(integrations: dict[str, Integration], config: Config): - """Generate supported_brands data.""" - supported_brands_path = config.root / "homeassistant/generated/supported_brands.py" - supported_brands_path.write_text( - f"{config.cache['supported_brands']}", encoding="utf-8" - ) diff --git a/script/pip_check b/script/pip_check index ae780b07d60d64..9ed327b54f4228 100755 --- a/script/pip_check +++ b/script/pip_check @@ -3,7 +3,7 @@ PIP_CACHE=$1 # Number of existing dependency conflicts # Update if a PR resolve one! -DEPENDENCY_CONFLICTS=4 +DEPENDENCY_CONFLICTS=3 PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) diff --git a/script/version_bump.py b/script/version_bump.py index f7dc37b5e22dca..4a38adbd677afc 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -116,7 +116,7 @@ def write_version(version): "PATCH_VERSION: Final = .*\n", f'PATCH_VERSION: Final = "{patch}"\n', content ) - with open("homeassistant/const.py", "wt") as fil: + with open("homeassistant/const.py", "w") as fil: fil.write(content) diff --git a/tests/common.py b/tests/common.py index cc2bc45481023a..14f3cdd47c2fd9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -20,6 +20,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +import voluptuous as vol from homeassistant import auth, config_entries, core as ha, loader from homeassistant.auth import ( @@ -42,7 +43,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import BLOCK_LOG_TIMEOUT, HomeAssistant +from homeassistant.core import BLOCK_LOG_TIMEOUT, HomeAssistant, ServiceCall, State from homeassistant.helpers import ( area_registry, device_registry, @@ -57,6 +58,7 @@ ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as date_util @@ -328,7 +330,9 @@ def clear_instance(event): return hass -def async_mock_service(hass, domain, service, schema=None): +def async_mock_service( + hass: HomeAssistant, domain: str, service: str, schema: vol.Schema | None = None +) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -417,18 +421,20 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P if integration is None: return pathlib.Path(__file__).parent.joinpath("fixtures", filename) - else: - return pathlib.Path(__file__).parent.joinpath( - "components", integration, "fixtures", filename - ) + + return pathlib.Path(__file__).parent.joinpath( + "components", integration, "fixtures", filename + ) -def load_fixture(filename, integration=None): +def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" return get_fixture_path(filename, integration).read_text() -def mock_state_change_event(hass, new_state, old_state=None): +def mock_state_change_event( + hass: HomeAssistant, new_state: State, old_state: State | None = None +) -> None: """Mock state change envent.""" event_data = {"entity_id": new_state.entity_id, "new_state": new_state} @@ -439,7 +445,7 @@ def mock_state_change_event(hass, new_state, old_state=None): @ha.callback -def mock_component(hass, component): +def mock_component(hass: HomeAssistant, component: str) -> None: """Mock a component is setup.""" if component in hass.config.components: AssertionError(f"Integration {component} is already setup") @@ -447,7 +453,10 @@ def mock_component(hass, component): hass.config.components.add(component) -def mock_registry(hass, mock_entries=None): +def mock_registry( + hass: HomeAssistant, + mock_entries: dict[str, entity_registry.RegistryEntry] | None = None, +) -> entity_registry.EntityRegistry: """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) if mock_entries is None: @@ -460,7 +469,9 @@ def mock_registry(hass, mock_entries=None): return registry -def mock_area_registry(hass, mock_entries=None): +def mock_area_registry( + hass: HomeAssistant, mock_entries: dict[str, area_registry.AreaEntry] | None = None +) -> area_registry.AreaRegistry: """Mock the Area Registry.""" registry = area_registry.AreaRegistry(hass) registry.areas = mock_entries or OrderedDict() @@ -469,7 +480,10 @@ def mock_area_registry(hass, mock_entries=None): return registry -def mock_device_registry(hass, mock_entries=None): +def mock_device_registry( + hass: HomeAssistant, + mock_entries: dict[str, device_registry.DeviceEntry] | None = None, +) -> device_registry.DeviceRegistry: """Mock the Device Registry.""" registry = device_registry.DeviceRegistry(hass) registry.devices = device_registry.DeviceRegistryItems() @@ -545,7 +559,9 @@ def mock_policy(self, policy): self._permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) -async def register_auth_provider(hass, config): +async def register_auth_provider( + hass: HomeAssistant, config: ConfigType +) -> auth_providers.AuthProvider: """Register an auth provider.""" provider = await auth_providers.auth_provider_from_config( hass, hass.auth._store, config @@ -909,17 +925,15 @@ async def mock_psc(hass, config_input, integration): SetupRecorderInstanceT = Callable[..., Awaitable[recorder.Recorder]] -def init_recorder_component(hass, add_config=None): +def init_recorder_component(hass, add_config=None, db_url="sqlite://"): """Initialize the recorder.""" config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: - config[recorder.CONF_DB_URL] = "sqlite://" # In memory DB + config[recorder.CONF_DB_URL] = db_url if recorder.CONF_COMMIT_INTERVAL not in config: config[recorder.CONF_COMMIT_INTERVAL] = 0 - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.migrate_schema" - ): + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): if recorder.DOMAIN not in hass.data: recorder_helper.async_initialize_recorder(hass) assert setup_component(hass, recorder.DOMAIN, {recorder.DOMAIN: config}) diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 8612a80598046d..55052b5ca3e6a4 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -30,7 +30,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration @@ -49,6 +49,7 @@ async def test_sensor_without_forecast(hass): assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_METERS assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE entry = registry.async_get("sensor.home_cloud_ceiling") assert entry @@ -435,6 +436,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust") assert entry @@ -447,6 +449,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind") assert entry @@ -579,6 +582,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get("direction") == "SSE" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_day_0d") assert entry @@ -592,6 +596,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "WNW" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_night_0d") assert entry @@ -605,6 +610,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "S" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust_day_0d") assert entry @@ -618,6 +624,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "WSW" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust_night_0d") assert entry @@ -697,7 +704,7 @@ async def test_manual_update_entity(hass): async def test_sensor_imperial_units(hass): """Test states of the sensor without forecast.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await init_integration(hass) state = hass.states.get("sensor.home_cloud_ceiling") diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 4bcfb60e7b67f8..2fdae7b9d6be4f 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -127,7 +127,9 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: "addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000", - } + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -149,7 +151,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000", - } + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -171,7 +175,13 @@ async def test_hassio_confirm( result = await hass.config_entries.flow.async_init( DOMAIN, data=HassioServiceInfo( - config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": 3000, + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -207,7 +217,13 @@ async def test_hassio_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, data=HassioServiceInfo( - config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": 3000, + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py new file mode 100644 index 00000000000000..47f20ccd883d18 --- /dev/null +++ b/tests/components/airnow/conftest.py @@ -0,0 +1,57 @@ +"""Define fixtures for AirNow tests.""" +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.airnow import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_API_KEY: "abc123", + CONF_LATITUDE: 34.053718, + CONF_LONGITUDE: -118.244842, + CONF_RADIUS: 75, + } + + +@pytest.fixture(name="data", scope="session") +def data_fixture(): + """Define a fixture for response data.""" + return json.loads(load_fixture("response.json", "airnow")) + + +@pytest.fixture(name="mock_api_get") +def mock_api_get_fixture(data): + """Define a fixture for a mock "get" coroutine function.""" + return AsyncMock(return_value=data) + + +@pytest.fixture(name="setup_airnow") +async def setup_airnow_fixture(hass, config, mock_api_get): + """Define a fixture to set up AirNow.""" + with patch("pyairnow.WebServiceAPI._get", mock_api_get), patch( + "homeassistant.components.airnow.config_flow.WebServiceAPI._get", mock_api_get + ), patch("homeassistant.components.airnow.PLATFORMS", []): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield diff --git a/tests/components/airnow/fixtures/__init__.py b/tests/components/airnow/fixtures/__init__.py new file mode 100644 index 00000000000000..328b7a792e208b --- /dev/null +++ b/tests/components/airnow/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define AirNow response fixture data.""" diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json new file mode 100644 index 00000000000000..91029f5531f22f --- /dev/null +++ b/tests/components/airnow/fixtures/response.json @@ -0,0 +1,47 @@ +[ + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "O3", + "AQI": 44, + "Category": { + "Number": 1, + "Name": "Good" + } + }, + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "PM2.5", + "AQI": 37, + "Category": { + "Number": 1, + "Name": "Good" + } + }, + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "PM10", + "AQI": 11, + "Category": { + "Number": 1, + "Name": "Good" + } + } +] diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 02236e826e5e28..dddd51c8450cc4 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,183 +1,75 @@ """Test the AirNow config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from pyairnow.errors import AirNowError, InvalidKeyError +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.airnow.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS -from tests.common import MockConfigEntry -CONFIG = { - CONF_API_KEY: "abc123", - CONF_LATITUDE: 34.053718, - CONF_LONGITUDE: -118.244842, - CONF_RADIUS: 75, -} - -# Mock AirNow Response -MOCK_RESPONSE = [ - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, -] - - -async def test_form(hass): +async def test_form(hass, config, setup_airnow): """Test we get the form.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} - with patch("pyairnow.WebServiceAPI._get", return_value=MOCK_RESPONSE), patch( - "homeassistant.components.airnow.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["data"] == CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["data"] == config -async def test_form_invalid_auth(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)]) +async def test_form_invalid_auth(hass, config, setup_airnow): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "pyairnow.WebServiceAPI._get", - side_effect=InvalidKeyError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_invalid_location(hass): +@pytest.mark.parametrize("data", [{}]) +async def test_form_invalid_location(hass, config, setup_airnow): """Test we handle invalid location.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch("pyairnow.WebServiceAPI._get", return_value={}): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_location"} -async def test_form_cannot_connect(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=AirNowError)]) +async def test_form_cannot_connect(hass, config, setup_airnow): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "pyairnow.WebServiceAPI._get", - side_effect=AirNowError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=RuntimeError)]) +async def test_form_unexpected(hass, config, setup_airnow): """Test we handle an unexpected error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.airnow.config_flow.validate_input", - side_effect=RuntimeError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} -async def test_entry_already_exists(hass): +async def test_entry_already_exists(hass, config, config_entry): """Test that the form aborts if the Lat/Lng is already configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - mock_id = f"{CONFIG[CONF_LATITUDE]}-{CONFIG[CONF_LONGITUDE]}" - mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=mock_id) - mock_entry.add_to_hass(hass) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "abort" assert result2["reason"] == "already_configured" diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py new file mode 100644 index 00000000000000..76a8a1dc0b276d --- /dev/null +++ b/tests/components/airnow/test_diagnostics.py @@ -0,0 +1,43 @@ +"""Test AirNow diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airnow): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "airnow", + "title": REDACTED, + "data": { + "api_key": REDACTED, + "latitude": REDACTED, + "longitude": REDACTED, + "radius": 75, + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + "data": { + "O3": 0.048, + "PM2.5": 8.9, + "HourObserved": 15, + "DateObserved": "2020-12-20", + "StateCode": REDACTED, + "ReportingArea": REDACTED, + "Latitude": REDACTED, + "Longitude": REDACTED, + "PM10": 12, + "AQI": 44, + "Category.Number": 1, + "Category.Name": "Good", + "Pollutant": "O3", + }, + } diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py new file mode 100644 index 00000000000000..7f8df35f263e01 --- /dev/null +++ b/tests/components/airthings_ble/__init__.py @@ -0,0 +1,99 @@ +"""Tests for the Airthings BLE integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): + """Patch airthings-ble device fetcher with given values and effects.""" + return patch.object( + AirthingsBluetoothDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "cc-cc-cc-cc-cc-cc", + ), + advertisement=generate_advertisement_data( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +WAVE_DEVICE_INFO = AirthingsDevice( + hw_version="REV A", + sw_version="G-BLE-1.5.3-master+0", + name="Airthings Wave+", + identifier="123456", + sensors={ + "illuminance": 25, + "battery": 85, + "humidity": 60.0, + "radon_1day_avg": 30, + "radon_longterm_avg": 30, + "temperature": 21.0, + "co2": 500.0, + "voc": 155.0, + "radon_1day_level": "very low", + "radon_longterm_level": "very low", + "pressure": 1020, + }, + address="cc:cc:cc:cc:cc:cc", +) diff --git a/tests/components/airthings_ble/conftest.py b/tests/components/airthings_ble/conftest.py new file mode 100644 index 00000000000000..3df082c4361d67 --- /dev/null +++ b/tests/components/airthings_ble/conftest.py @@ -0,0 +1,8 @@ +"""Define fixtures available for all tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py new file mode 100644 index 00000000000000..ddddcdbc94a7cb --- /dev/null +++ b/tests/components/airthings_ble/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Airthings BLE config flow.""" +from unittest.mock import patch + +from airthings_ble import AirthingsDevice +from bleak import BleakError + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + UNKNOWN_SERVICE_INFO, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + patch_airthings_ble, + patch_async_ble_device_from_address, + patch_async_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device.""" + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "Airthings Wave+ (123456)"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant): + """Test discovery via bluetooth but there's no BLEDevice.""" + with patch_async_ble_device_from_address(None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_bluetooth_discovery_airthings_ble_update_failed( + hass: HomeAssistant, +): + """Test discovery via bluetooth but there's an exception from airthings-ble.""" + for loop in [(Exception(), "unknown"), (BleakError(), "cannot_connect")]: + exc, reason = loop + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=exc): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant): + """Test the user initiated form.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave+ (123456)" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_user_setup_no_device(hass: HomeAssistant): + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant): + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, WAVE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_unknown_error(hass: HomeAssistant): + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(None, Exception()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant): + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=BleakError("An error")): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index 81b22f19cc5610..3e83b41a5af72c 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -50,7 +50,7 @@ def config_fixture(hass): } -@pytest.fixture(name="data", scope="session") +@pytest.fixture(name="data", scope="package") def data_fixture(): """Define an update coordinator data example.""" return json.loads(load_fixture("data.json", "airvisual")) diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 5b68644bb7e77b..72ed5298f96a40 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -8,20 +8,28 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisua """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "airvisual", + "title": REDACTED, "data": { - "api_key": REDACTED, "integration_type": "Geographical Location by Latitude/Longitude", + "api_key": REDACTED, "latitude": REDACTED, "longitude": REDACTED, }, - "options": { - "show_on_map": True, - }, + "options": {"show_on_map": True}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "city": REDACTED, + "state": REDACTED, "country": REDACTED, + "location": {"type": "Point", "coordinates": REDACTED}, "current": { "weather": { "ts": "2021-09-03T21:00:00.000Z", @@ -40,10 +48,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisua "maincn": "p2", }, }, - "location": { - "coordinates": REDACTED, - "type": "Point", - }, - "state": REDACTED, }, } diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index ef21b463a127f3..f1543892b6b3b7 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -5,12 +5,21 @@ import pytest import homeassistant.components.alert as alert -from homeassistant.components.alert import DOMAIN +from homeassistant.components.alert.const import ( + CONF_ALERT_MESSAGE, + CONF_DATA, + CONF_DONE_MESSAGE, + CONF_NOTIFIERS, + CONF_SKIP_FIRST, + CONF_TITLE, + DOMAIN, +) import homeassistant.components.notify as notify from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME, + CONF_REPEAT, CONF_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -19,9 +28,11 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component +from tests.common import async_mock_service + NAME = "alert_test" DONE_MESSAGE = "alert_gone" NOTIFIER = "test" @@ -31,17 +42,17 @@ TEST_TITLE = "sensor.test" TEST_DATA = {"data": {"inline_keyboard": ["Close garage:/close_garage"]}} TEST_CONFIG = { - alert.DOMAIN: { + DOMAIN: { NAME: { CONF_NAME: NAME, - alert.CONF_DONE_MESSAGE: DONE_MESSAGE, + CONF_DONE_MESSAGE: DONE_MESSAGE, CONF_ENTITY_ID: TEST_ENTITY, CONF_STATE: STATE_ON, - alert.CONF_REPEAT: 30, - alert.CONF_SKIP_FIRST: False, - alert.CONF_NOTIFIERS: [NOTIFIER], - alert.CONF_TITLE: TITLE, - alert.CONF_DATA: {}, + CONF_REPEAT: 30, + CONF_SKIP_FIRST: False, + CONF_NOTIFIERS: [NOTIFIER], + CONF_TITLE: TITLE, + CONF_DATA: {}, } } } @@ -59,73 +70,24 @@ None, None, ] -ENTITY_ID = f"{alert.DOMAIN}.{NAME}" - - -@callback -def async_turn_on(hass, entity_id): - """Async reset the alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) - - -@callback -def async_turn_off(hass, entity_id): - """Async acknowledge the alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) - - -@callback -def async_toggle(hass, entity_id): - """Async toggle acknowledgment of alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)) +ENTITY_ID = f"{DOMAIN}.{NAME}" @pytest.fixture -def mock_notifier(hass): +def mock_notifier(hass: HomeAssistant) -> list[ServiceCall]: """Mock for notifier.""" - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - - return events - - -async def test_is_on(hass): - """Test is_on method.""" - hass.states.async_set(ENTITY_ID, STATE_ON) - await hass.async_block_till_done() - assert alert.is_on(hass, ENTITY_ID) - hass.states.async_set(ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() - assert not alert.is_on(hass, ENTITY_ID) + return async_mock_service(hass, notify.DOMAIN, NOTIFIER) async def test_setup(hass): """Test setup method.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) assert hass.states.get(ENTITY_ID).state == STATE_IDLE async def test_fire(hass, mock_notifier): """Test the alert firing.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON @@ -133,11 +95,16 @@ async def test_fire(hass, mock_notifier): async def test_silence(hass, mock_notifier): """Test silencing the alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - async_turn_off(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_OFF # alert should not be silenced on next fire @@ -151,82 +118,119 @@ async def test_silence(hass, mock_notifier): async def test_reset(hass, mock_notifier): """Test resetting the alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - async_turn_off(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_OFF - async_turn_on(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_ON async def test_toggle(hass, mock_notifier): """Test toggling alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON - async_toggle(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - async_toggle(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_ON -async def test_notification_no_done_message(hass): +async def test_notification_no_done_message( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: """Test notifications.""" - events = [] config = deepcopy(TEST_CONFIG) - del config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) + del config[DOMAIN][NAME][CONF_DONE_MESSAGE] - assert await async_setup_component(hass, alert.DOMAIN, config) - assert len(events) == 0 + assert await async_setup_component(hass, DOMAIN, config) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 1 hass.states.async_set("sensor.test", STATE_OFF) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 1 -async def test_notification(hass): +async def test_notification( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: """Test notifications.""" - events = [] + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) + assert len(mock_notifier) == 0 - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) + hass.states.async_set("sensor.test", STATE_ON) + await hass.async_block_till_done() + assert len(mock_notifier) == 1 + + hass.states.async_set("sensor.test", STATE_OFF) + await hass.async_block_till_done() + assert len(mock_notifier) == 2 - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) - assert len(events) == 0 +async def test_no_notifiers( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: + """Test we send no notifications when there are not no.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + NAME: { + CONF_NAME: NAME, + CONF_ENTITY_ID: TEST_ENTITY, + CONF_STATE: STATE_ON, + CONF_REPEAT: 30, + } + } + }, + ) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_OFF) await hass.async_block_till_done() - assert len(events) == 2 + assert len(mock_notifier) == 0 async def test_sending_non_templated_notification(hass, mock_notifier): """Test notifications.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -238,8 +242,8 @@ async def test_sending_non_templated_notification(hass, mock_notifier): async def test_sending_templated_notification(hass, mock_notifier): """Test templated notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_ALERT_MESSAGE] = TEMPLATE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_ALERT_MESSAGE] = TEMPLATE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -251,8 +255,8 @@ async def test_sending_templated_notification(hass, mock_notifier): async def test_sending_templated_done_notification(hass, mock_notifier): """Test templated notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE] = TEMPLATE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_DONE_MESSAGE] = TEMPLATE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -266,8 +270,8 @@ async def test_sending_templated_done_notification(hass, mock_notifier): async def test_sending_titled_notification(hass, mock_notifier): """Test notifications.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_TITLE] = TITLE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_TITLE] = TITLE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -279,8 +283,8 @@ async def test_sending_titled_notification(hass, mock_notifier): async def test_sending_data_notification(hass, mock_notifier): """Test notifications.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_DATA] = TEST_DATA - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_DATA] = TEST_DATA + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -289,25 +293,16 @@ async def test_sending_data_notification(hass, mock_notifier): assert last_event.data[notify.ATTR_DATA] == TEST_DATA -async def test_skipfirst(hass): +async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test skipping first notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_SKIP_FIRST] = True - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - - assert await async_setup_component(hass, alert.DOMAIN, config) - assert len(events) == 0 + config[DOMAIN][NAME][CONF_SKIP_FIRST] = True + assert await async_setup_component(hass, DOMAIN, config) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 0 + assert len(mock_notifier) == 0 async def test_done_message_state_tracker_reset_on_cancel(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 9d329e76c45c35..e2ae8741f203e6 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -30,7 +30,7 @@ from homeassistant.core import Context from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( MockConfig, @@ -2020,7 +2020,7 @@ async def test_unknown_sensor(hass): async def test_thermostat(hass): """Test thermostat discovery.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM device = ( "climate.test_thermostat", "cool", diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 3bf2db14b95e29..511a5cf08dc47f 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -53,7 +53,9 @@ async def test_hassio(hass): DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, data=HassioServiceInfo( - config={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"} + config={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"}, + name="Almond add-on", + slug="almond", ), ) @@ -90,7 +92,9 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - result = await flow.async_step_hassio(HassioServiceInfo(config={})) + result = await flow.async_step_hassio( + HassioServiceInfo(config={}, name="Almond add-on", slug="almond") + ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py index 680fa82303dfb5..89dc4e88fb362d 100644 --- a/tests/components/ambient_station/conftest.py +++ b/tests/components/ambient_station/conftest.py @@ -28,7 +28,7 @@ def config_entry_fixture(hass, config): return entry -@pytest.fixture(name="devices", scope="session") +@pytest.fixture(name="devices", scope="package") def devices_fixture(): """Define devices data.""" return json.loads(load_fixture("devices.json", "ambient_station")) @@ -48,7 +48,7 @@ async def setup_ambient_station_fixture(hass, config, devices): yield -@pytest.fixture(name="station_data", scope="session") +@pytest.fixture(name="station_data", scope="package") def station_data_fixture(): """Define devices data.""" return json.loads(load_fixture("station_data.json", "ambient_station")) diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 63d5fcff7a18a3..e6285afa17aa9f 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -13,45 +13,54 @@ async def test_entry_diagnostics( ambient.stations = station_data assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "ambient_station", + "title": REDACTED, "data": {"api_key": REDACTED, "app_key": REDACTED}, - "title": "Mock Title", + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "stations": { "devices": [ { - "apiKey": REDACTED, - "info": {"location": REDACTED, "name": "Side Yard"}, + "macAddress": REDACTED, "lastData": { - "baromabsin": 25.016, - "baromrelin": 29.953, - "batt_co2": 1, - "dailyrainin": 0, - "date": "2022-01-19T22:38:00.000Z", "dateutc": 1642631880000, - "deviceId": REDACTED, - "dewPoint": 17.75, - "dewPointin": 37, - "eventrainin": 0, - "feelsLike": 21, - "feelsLikein": 69.1, - "hourlyrainin": 0, - "humidity": 87, + "tempinf": 70.9, "humidityin": 29, - "lastRain": "2022-01-07T19:45:00.000Z", + "baromrelin": 29.953, + "baromabsin": 25.016, + "tempf": 21, + "humidity": 87, + "winddir": 25, + "windspeedmph": 0.2, + "windgustmph": 1.1, "maxdailygust": 9.2, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, "monthlyrainin": 0.409, - "solarradiation": 11.62, - "tempf": 21, - "tempinf": 70.9, "totalrainin": 35.398, - "tz": REDACTED, + "solarradiation": 11.62, "uv": 0, - "weeklyrainin": 0, - "winddir": 25, - "windgustmph": 1.1, - "windspeedmph": 0.2, + "batt_co2": 1, + "feelsLike": 21, + "dewPoint": 17.75, + "feelsLikein": 69.1, + "dewPointin": 37, + "lastRain": "2022-01-07T19:45:00.000Z", + "deviceId": REDACTED, + "tz": REDACTED, + "date": "2022-01-19T22:38:00.000Z", }, - "macAddress": REDACTED, + "info": {"name": "Side Yard", "location": REDACTED}, + "apiKey": REDACTED, } ], "method": "subscribe", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 82a611264321be..b73338add35975 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -451,7 +451,7 @@ async def test_send_with_no_energy(hass, aioclient_mock): assert "energy" not in postdata -async def test_send_with_no_energy_config(hass, aioclient_mock, recorder_mock): +async def test_send_with_no_energy_config(recorder_mock, hass, aioclient_mock): """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -473,7 +473,7 @@ async def test_send_with_no_energy_config(hass, aioclient_mock, recorder_mock): assert not postdata["energy"]["configured"] -async def test_send_with_energy_config(hass, aioclient_mock, recorder_mock): +async def test_send_with_energy_config(recorder_mock, hass, aioclient_mock): """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) diff --git a/tests/components/android_ip_webcam/test_config_flow.py b/tests/components/android_ip_webcam/test_config_flow.py index d203ef15e635be..881585ed5dc415 100644 --- a/tests/components/android_ip_webcam/test_config_flow.py +++ b/tests/components/android_ip_webcam/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Android IP Webcam config flow.""" -from datetime import timedelta from unittest.mock import Mock, patch import aiohttp @@ -45,35 +44,6 @@ async def test_form(hass: HomeAssistant, aioclient_mock_fixture) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant, aioclient_mock_fixture) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.android_ip_webcam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IP Webcam", - "host": "1.1.1.1", - "port": 8080, - "timeout": 10, - "scan_interval": timedelta(seconds=30), - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IP Webcam" - assert result2["data"] == { - "name": "IP Webcam", - "host": "1.1.1.1", - "port": 8080, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_device_already_configured( hass: HomeAssistant, aioclient_mock_fixture ) -> None: diff --git a/tests/components/android_ip_webcam/test_init.py b/tests/components/android_ip_webcam/test_init.py index 1fee1a5c388c87..fa5f551e9b1a80 100644 --- a/tests/components/android_ip_webcam/test_init.py +++ b/tests/components/android_ip_webcam/test_init.py @@ -1,8 +1,6 @@ """Tests for the Android IP Webcam integration.""" -from collections.abc import Awaitable -from typing import Callable from unittest.mock import Mock import aiohttp @@ -10,10 +8,8 @@ from homeassistant.components.android_ip_webcam.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs from tests.test_util.aiohttp import AiohttpClientMocker MOCK_CONFIG_DATA = { @@ -25,21 +21,6 @@ } -async def test_setup( - hass: HomeAssistant, - aioclient_mock_fixture, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], -) -> None: - """Test integration failed due to an error.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: [MOCK_CONFIG_DATA]}) - assert hass.config_entries.async_entries(DOMAIN) - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" - - async def test_successful_config_entry( hass: HomeAssistant, aioclient_mock_fixture ) -> None: diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index f8bec435dc65aa..e62fb4ba52c47f 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -2,10 +2,9 @@ from unittest.mock import AsyncMock, patch from anthemav.device_error import DeviceError -import pytest from homeassistant.components.anthemav.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -95,36 +94,11 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_import_configuration( - hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock -) -> None: - """Test we import existing configuration.""" - config = { - "host": "1.1.1.1", - "port": 14999, - "name": "Anthem Av Import", - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - "host": "1.1.1.1", - "port": 14999, - "name": "Anthem Av Import", - "mac": "00:00:00:00:00:01", - "model": "MRX 520", - } - - -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_device_already_configured( hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we import existing configuration.""" config = { @@ -134,7 +108,7 @@ async def test_device_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=config + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result.get("type") == FlowResultType.ABORT diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 3cdf0c3a477ac1..f40309bf7f6eff 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -15,6 +15,7 @@ EVENT_AUTOMATION_RELOADED, EVENT_AUTOMATION_TRIGGERED, SERVICE_TRIGGER, + AutomationEntity, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -45,6 +46,7 @@ _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component +from homeassistant.util import yaml import homeassistant.util.dt as dt_util from tests.common import ( @@ -720,6 +722,7 @@ def running_cb(event): blocking=True, ) else: + config[automation.DOMAIN]["alias"] = "goodbye" with patch( "homeassistant.config.load_yaml_config_file", autospec=True, @@ -735,6 +738,326 @@ def running_cb(event): assert len(calls) == (1 if service == "turn_off_no_stop" else 0) +async def test_reload_unchanged_does_not_stop(hass, calls): + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + automation.DOMAIN: { + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.automation"}, + ], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config) + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + hass.bus.async_fire("test_event") + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_reload_moved_automation_without_alias(hass, calls): + """Test that changing the order of automations without alias triggers reload.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "automation_with_alias", + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 2 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.automation_0") + assert not hass.states.get("automation.automation_1") + assert hass.states.get("automation.automation_with_alias") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reverse the order of the automations + config[automation.DOMAIN].reverse() + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + assert not hass.states.get("automation.automation_0") + assert hass.states.get("automation.automation_1") + assert hass.states.get("automation.automation_with_alias") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_reload_identical_automations_without_id(hass, calls): + """Test reloading of identical automations without id.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 3 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + + # Reload the automations without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 6 + + # Remove two clones + del config[automation.DOMAIN][-1] + del config[automation.DOMAIN][-1] + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 7 + + # Add two clones + config[automation.DOMAIN].append(config[automation.DOMAIN][-1]) + config[automation.DOMAIN].append(config[automation.DOMAIN][-1]) + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 2 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 10 + + +@pytest.mark.parametrize( + "automation_config", + ( + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + # An automation using templates + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "{{ 'test.automation' }}"}], + }, + # An automation using blueprint + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + } + }, + # An automation using blueprint with templated input + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "{{ 'test_event' }}", + "service_to_call": "{{ 'test.automation' }}", + "a_number": 5, + }, + } + }, + ), +) +async def test_reload_unchanged_automation(hass, calls, automation_config): + """Test an unmodified automation is not reloaded.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = {automation.DOMAIN: [automation_config]} + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the automations without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_reload_automation_when_blueprint_changes(hass, calls): + """Test an automation is updated at reload if the blueprint has changed.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + } + } + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the automations without any change, but with updated blueprint + blueprint_path = automation.async_get_blueprints(hass).blueprint_folder + blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") + blueprint_config["action"] = [blueprint_config["action"]] + blueprint_config["action"].append(blueprint_config["action"][-1]) + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), patch( + "homeassistant.components.blueprint.models.yaml.load_yaml", + autospec=True, + return_value=blueprint_config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + + async def test_automation_restore_state(hass): """Ensure states are restored on startup.""" time = dt_util.utcnow() diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 4067393b76c796..8ce543a3f475dc 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -27,7 +27,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_exclude_attributes(hass, recorder_mock, calls): +async def test_exclude_attributes(recorder_mock, hass, calls): """Test automation registered attributes to be excluded.""" assert await async_setup_component( hass, diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index 0ad1de9a7a51e3..3b1dcaea1e5e32 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -1,5 +1,6 @@ """Tests for the aws component config and setup.""" -from unittest.mock import AsyncMock, MagicMock, patch as async_patch +import json +from unittest.mock import AsyncMock, MagicMock, call, patch as async_patch from homeassistant.setup import async_setup_component @@ -13,6 +14,7 @@ def __init__(self, *args, **kwargs): self.invoke = AsyncMock() self.publish = AsyncMock() self.send_message = AsyncMock() + self.put_events = AsyncMock() def create_client(self, *args, **kwargs): """Create a mocked client.""" @@ -23,6 +25,7 @@ def create_client(self, *args, **kwargs): invoke=self.invoke, # lambda publish=self.publish, # sns send_message=self.send_message, # sqs + put_events=self.put_events, # events ) ), __aexit__=AsyncMock(), @@ -289,3 +292,111 @@ async def test_service_call_extra_data(hass): "AWS.SNS.SMS.SenderID": {"StringValue": "HA-notify", "DataType": "String"} }, ) + + +async def test_events_service_call(hass): + """Test events service (EventBridge) call works as expected.""" + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( + hass, + "aws", + { + "aws": { + "notify": [ + { + "service": "events", + "name": "Events Test", + "region_name": "us-east-1", + } + ] + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "events_test") is True + + mock_session.put_events.return_value = { + "Entries": [{"EventId": "", "ErrorCode": 0, "ErrorMessage": "test-error"}] + } + + await hass.services.async_call( + "notify", + "events_test", + { + "message": "test", + "target": "ARN", + "data": {}, + }, + blocking=True, + ) + + mock_session.put_events.assert_called_once_with( + Entries=[ + { + "EventBusName": "ARN", + "Detail": json.dumps({"message": "test"}), + "DetailType": "", + "Source": "homeassistant", + "Resources": [], + } + ] + ) + + +async def test_events_service_call_10_targets(hass): + """Test events service (EventBridge) call works with more than 10 targets.""" + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( + hass, + "aws", + { + "aws": { + "notify": [ + { + "service": "events", + "name": "Events Test", + "region_name": "us-east-1", + } + ] + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "events_test") is True + await hass.services.async_call( + "notify", + "events_test", + { + "message": "", + "target": [f"eventbus{i}" for i in range(11)], + "data": { + "detail_type": "test_event", + "detail": {"eventkey": "eventvalue"}, + "source": "HomeAssistant-test", + "resources": ["resource1", "resource2"], + }, + }, + blocking=True, + ) + + entry = { + "Detail": json.dumps({"eventkey": "eventvalue"}), + "DetailType": "test_event", + "Source": "HomeAssistant-test", + "Resources": ["resource1", "resource2"], + } + + mock_session.put_events.assert_has_calls( + [ + call(Entries=[entry | {"EventBusName": f"eventbus{i}"} for i in range(10)]), + call(Entries=[entry | {"EventBusName": "eventbus10"}]), + ] + ) diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index e16033c66a2e49..4dd42705026183 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -17,6 +17,7 @@ STATE_UNKNOWN, ) from homeassistant.core import Context, callback +from homeassistant.helpers.entity_registry import async_get as async_get_entities from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import async_get from homeassistant.setup import async_setup_component @@ -31,6 +32,8 @@ async def test_load_values_when_added_to_hass(hass): "binary_sensor": { "name": "Test_Binary", "platform": "bayesian", + "unique_id": "3b4c9563-5e84-4167-8fe7-8f507e796d72", + "device_class": "connectivity", "observations": [ { "platform": "state", @@ -51,7 +54,14 @@ async def test_load_values_when_added_to_hass(hass): assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + entity_registry = async_get_entities(hass) + assert ( + entity_registry.entities["binary_sensor.test_binary"].unique_id + == "bayesian-3b4c9563-5e84-4167-8fe7-8f507e796d72" + ) + state = hass.states.get("binary_sensor.test_binary") + assert state.attributes.get("device_class") == "connectivity" assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 diff --git a/tests/components/blebox/test_air_quality.py b/tests/components/blebox/test_air_quality.py deleted file mode 100644 index 8b5bc67d4bc0fe..00000000000000 --- a/tests/components/blebox/test_air_quality.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Blebox air_quality tests.""" -import logging -from unittest.mock import AsyncMock, PropertyMock - -import blebox_uniapi -import pytest - -from homeassistant.components.air_quality import ATTR_PM_0_1, ATTR_PM_2_5, ATTR_PM_10 -from homeassistant.const import ATTR_ICON, STATE_UNKNOWN -from homeassistant.helpers import device_registry as dr - -from .conftest import async_setup_entity, mock_feature - - -@pytest.fixture(name="airsensor") -def airsensor_fixture(): - """Return a default air quality fixture.""" - feature = mock_feature( - "air_qualities", - blebox_uniapi.air_quality.AirQuality, - unique_id="BleBox-airSensor-1afe34db9437-0.air", - full_name="airSensor-0.air", - device_class=None, - pm1=None, - pm2_5=None, - pm10=None, - ) - product = feature.product - type(product).name = PropertyMock(return_value="My air sensor") - type(product).model = PropertyMock(return_value="airSensor") - return (feature, "air_quality.airsensor_0_air") - - -async def test_init(airsensor, hass, config): - """Test airSensor default state.""" - - _, entity_id = airsensor - entry = await async_setup_entity(hass, config, entity_id) - assert entry.unique_id == "BleBox-airSensor-1afe34db9437-0.air" - - state = hass.states.get(entity_id) - assert state.name == "airSensor-0.air" - - assert ATTR_PM_0_1 not in state.attributes - assert ATTR_PM_2_5 not in state.attributes - assert ATTR_PM_10 not in state.attributes - - assert state.attributes[ATTR_ICON] == "mdi:blur" - - assert state.state == STATE_UNKNOWN - - device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) - - assert device.name == "My air sensor" - assert device.identifiers == {("blebox", "abcd0123ef5678")} - assert device.manufacturer == "BleBox" - assert device.model == "airSensor" - assert device.sw_version == "1.23" - - -async def test_update(airsensor, hass, config): - """Test air quality sensor state after update.""" - - feature_mock, entity_id = airsensor - - def initial_update(): - feature_mock.pm1 = 49 - feature_mock.pm2_5 = 222 - feature_mock.pm10 = 333 - - feature_mock.async_update = AsyncMock(side_effect=initial_update) - await async_setup_entity(hass, config, entity_id) - - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_PM_0_1] == 49 - assert state.attributes[ATTR_PM_2_5] == 222 - assert state.attributes[ATTR_PM_10] == 333 - - assert state.state == "222" - - -async def test_update_failure(airsensor, hass, config, caplog): - """Test that update failures are logged.""" - - caplog.set_level(logging.ERROR) - - feature_mock, entity_id = airsensor - feature_mock.async_update = AsyncMock(side_effect=blebox_uniapi.error.ClientError) - await async_setup_entity(hass, config, entity_id) - - assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py new file mode 100644 index 00000000000000..c9181762f3eccf --- /dev/null +++ b/tests/components/blebox/test_binary_sensor.py @@ -0,0 +1,46 @@ +"""Blebox binary_sensor entities test.""" +from unittest.mock import AsyncMock, PropertyMock + +import blebox_uniapi +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import async_setup_entity, mock_feature + + +@pytest.fixture(name="rainsensor") +def airsensor_fixture() -> tuple[AsyncMock, str]: + """Return a default air quality fixture.""" + feature: AsyncMock = mock_feature( + "binary_sensors", + blebox_uniapi.binary_sensor.Rain, + unique_id="BleBox-windRainSensor-ea68e74f4f49-0.rain", + full_name="windRainSensor-0.rain", + device_class="moisture", + ) + product = feature.product + type(product).name = PropertyMock(return_value="My rain sensor") + type(product).model = PropertyMock(return_value="rainSensor") + return feature, "binary_sensor.windrainsensor_0_rain" + + +async def test_init(rainsensor: AsyncMock, hass: HomeAssistant, config: dict): + """Test binary_sensor initialisation.""" + _, entity_id = rainsensor + entry = await async_setup_entity(hass, config, entity_id) + assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain" + + state = hass.states.get(entity_id) + assert state.name == "windRainSensor-0.rain" + + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE + assert state.state == STATE_ON + + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + + assert device.name == "My rain sensor" diff --git a/tests/components/blebox/test_init.py b/tests/components/blebox/test_init.py index c0add2696b5745..9320c3c271c86b 100644 --- a/tests/components/blebox/test_init.py +++ b/tests/components/blebox/test_init.py @@ -7,7 +7,7 @@ from homeassistant.components.blebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from .conftest import mock_config, patch_product_identify +from .conftest import mock_config, patch_product_identify, setup_product_mock async def test_setup_failure(hass, caplog): @@ -44,7 +44,7 @@ async def test_setup_failure_on_connection(hass, caplog): async def test_unload_config_entry(hass): """Test that unloading works properly.""" - patch_product_identify(None) + setup_product_mock("switches", []) entry = mock_config() entry.add_to_hass(hass) diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index b7f6d421a1232d..d876da8b0b633e 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, STATE_UNKNOWN, TEMP_CELSIUS, ) @@ -17,9 +18,27 @@ from .conftest import async_setup_entity, mock_feature +@pytest.fixture(name="airsensor") +def airsensor_fixture(): + """Return a default AirQuality sensor mock.""" + feature = mock_feature( + "sensors", + blebox_uniapi.sensor.AirQuality, + unique_id="BleBox-airSensor-1afe34db9437-0.air", + full_name="airSensor-0.air", + device_class="pm1", + unit="concentration_of_mp", + native_value=None, + ) + product = feature.product + type(product).name = PropertyMock(return_value="My air sensor") + type(product).model = PropertyMock(return_value="airSensor") + return (feature, "sensor.airsensor_0_air") + + @pytest.fixture(name="tempsensor") def tempsensor_fixture(): - """Return a default sensor mock.""" + """Return a default Temperature sensor mock.""" feature = mock_feature( "sensors", blebox_uniapi.sensor.Temperature, @@ -28,6 +47,7 @@ def tempsensor_fixture(): device_class="temperature", unit="celsius", current=None, + native_value=None, ) product = feature.product type(product).name = PropertyMock(return_value="My temperature sensor") @@ -65,7 +85,7 @@ async def test_update(tempsensor, hass, config): feature_mock, entity_id = tempsensor def initial_update(): - feature_mock.current = 25.18 + feature_mock.native_value = 25.18 feature_mock.async_update = AsyncMock(side_effect=initial_update) await async_setup_entity(hass, config, entity_id) @@ -85,3 +105,46 @@ async def test_update_failure(tempsensor, hass, config, caplog): await async_setup_entity(hass, config, entity_id) assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text + + +async def test_airsensor_init(airsensor, hass, config): + """Test airSensor default state.""" + + _, entity_id = airsensor + entry = await async_setup_entity(hass, config, entity_id) + assert entry.unique_id == "BleBox-airSensor-1afe34db9437-0.air" + + state = hass.states.get(entity_id) + assert state.name == "airSensor-0.air" + + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PM1 + assert state.state == STATE_UNKNOWN + + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + + assert device.name == "My air sensor" + assert device.identifiers == {("blebox", "abcd0123ef5678")} + assert device.manufacturer == "BleBox" + assert device.model == "airSensor" + assert device.sw_version == "1.23" + + +async def test_airsensor_update(airsensor, hass, config): + """Test air quality sensor state after update.""" + + feature_mock, entity_id = airsensor + + def initial_update(): + feature_mock.native_value = 49 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + await async_setup_entity(hass, config, entity_id) + + state = hass.states.get(entity_id) + assert ( + state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + + assert state.state == "49" diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 02ed94709dbac5..589025a08badf0 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -224,7 +224,7 @@ async def test_domain_blueprints_caching(domain_bps): assert await domain_bps.async_get_blueprint("something") is obj obj_2 = object() - domain_bps.async_reset_cache() + await domain_bps.async_reset_cache() # Now we call this method again. with patch.object(domain_bps, "_load_blueprint", return_value=obj_2): diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index a836740bb9bbc3..e695f18c42f96b 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -2,6 +2,7 @@ import time +from typing import Any from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -27,8 +28,27 @@ "inject_bluetooth_service_info", "patch_all_discovered_devices", "patch_discovered_devices", + "generate_advertisement_data", ) +ADVERTISEMENT_DATA_DEFAULTS = { + "local_name": "", + "manufacturer_data": {}, + "service_data": {}, + "service_uuids": [], + "rssi": -127, + "platform_data": ((),), + "tx_power": -127, +} + + +def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: + """Generate advertisement data with defaults.""" + new = kwargs.copy() + for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): + new.setdefault(key, value) + return AdvertisementData(**new) + def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" @@ -77,7 +97,7 @@ def inject_advertisement_with_time_and_source_connectable( models.BluetoothServiceInfoBleak( name=adv.local_name or device.name or device.address, address=device.address, - rssi=device.rssi, + rssi=adv.rssi, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, @@ -94,17 +114,17 @@ def inject_bluetooth_service_info_bleak( hass: HomeAssistant, info: models.BluetoothServiceInfoBleak ) -> None: """Inject an advertisement into the manager with connectable status.""" - advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + advertisement_data = generate_advertisement_data( local_name=None if info.name == "" else info.name, manufacturer_data=info.manufacturer_data, service_data=info.service_data, service_uuids=info.service_uuids, + rssi=info.rssi, ) device = BLEDevice( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, - rssi=info.rssi, ) inject_advertisement_with_time_and_source_connectable( hass, @@ -120,17 +140,17 @@ def inject_bluetooth_service_info( hass: HomeAssistant, info: models.BluetoothServiceInfo ) -> None: """Inject a BluetoothServiceInfo into the manager.""" - advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + advertisement_data = generate_advertisement_data( # type: ignore[no-untyped-call] local_name=None if info.name == "" else info.name, manufacturer_data=info.manufacturer_data, service_data=info.service_data, service_uuids=info.service_uuids, + rssi=info.rssi, ) device = BLEDevice( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, - rssi=info.rssi, ) inject_advertisement(hass, device, advertisement_data) @@ -138,7 +158,9 @@ def inject_bluetooth_service_info( def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: """Mock all the discovered devices from all the scanners.""" return patch.object( - _get_manager(), "async_all_discovered_devices", return_value=mock_discovered + _get_manager(), + "_async_all_discovered_addresses", + return_value={ble_device.address for ble_device in mock_discovered}, ) diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py new file mode 100644 index 00000000000000..6eb2b5a968eabd --- /dev/null +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -0,0 +1,432 @@ +"""Tests for the Bluetooth integration advertisement tracking.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from bleak.backends.scanner import AdvertisementData, BLEDevice + +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 ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) +from homeassistant.components.bluetooth.models import BaseHaScanner +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +from . import ( + generate_advertisement_data, + inject_advertisement_with_time_and_source, + inject_advertisement_with_time_and_source_connectable, +) + +from tests.common import async_fire_time_changed + +ONE_HOUR_SECONDS = 3600 + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test we can determine the advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:18", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + ) + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a short advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:5C", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-100, + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "original", + ) + + switchbot_adv_better_rssi = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-30, + ) + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv_better_rssi, + start_monotonic_time + (i * 2), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-100, + ) + switchbot_device_went_unavailable = False + + class FakeScanner(BaseHaScanner): + """Fake scanner.""" + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices.""" + return {} + + scanner = FakeScanner(hass, "new") + cancel_scanner = async_register_scanner(hass, scanner, False) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + connectable=False, + ) + + switchbot_better_rssi_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-30, + ) + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device, + switchbot_better_rssi_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + connectable=False, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + cancel_scanner() + + # Now that the scanner is gone we should go back to the stack default timeout + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED, 2 * ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i**2), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 4c1e8f660b327e..69619ba76a741b 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -37,7 +37,6 @@ async def test_options_flow_disabled_not_setup( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() @@ -341,7 +340,6 @@ async def test_options_flow_disabled_macos( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() @@ -371,7 +369,6 @@ async def test_options_flow_enabled_linux( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 1da071a76abda3..a8d4d7aa14263d 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,12 +3,12 @@ from unittest.mock import ANY, patch -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS -from . import inject_advertisement +from . import generate_advertisement_data, inject_advertisement from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -110,13 +110,18 @@ async def test_diagnostics( "sw_version": "BlueZ 4.63", }, }, + "advertisement_tracker": { + "intervals": {}, + "sources": {}, + "timings": {}, + }, "connectable_history": [], - "history": [], + "all_history": [], "scanners": [ { "adapter": "hci0", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -127,7 +132,7 @@ async def test_diagnostics( { "adapter": "hci0", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -138,7 +143,7 @@ async def test_diagnostics( { "adapter": "hci1", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci1 (00:00:00:00:00:02)", @@ -161,7 +166,7 @@ async def test_diagnostics_macos( # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -205,28 +210,53 @@ async def test_diagnostics_macos( "sw_version": ANY, } }, + "advertisement_tracker": { + "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": ANY, + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, - "manufacturer_data": ANY, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, "name": "wohand", - "rssi": 0, + "rssi": -127, "service_data": {}, "service_uuids": [], "source": "local", "time": ANY, } ], - "history": [ + "all_history": [ { "address": "44:44:33:11:23:45", - "advertisement": ANY, + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, - "manufacturer_data": ANY, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, "name": "wohand", - "rssi": 0, + "rssi": -127, "service_data": {}, "service_uuids": [], "source": "local", @@ -237,7 +267,7 @@ async def test_diagnostics_macos( { "adapter": "Core Bluetooth", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "Core Bluetooth", diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2e311d9d97e620..c9a5e6c78a7e81 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -44,6 +44,7 @@ from . import ( _get_manager, async_setup_with_default_adapter, + generate_advertisement_data, inject_advertisement, inject_advertisement_with_time_and_source_connectable, patch_discovered_devices, @@ -334,7 +335,9 @@ async def test_discovery_match_by_service_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) await hass.async_block_till_done() @@ -342,7 +345,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_config_flow.mock_calls) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -379,7 +382,9 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", True @@ -389,7 +394,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -424,7 +429,9 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", False @@ -434,7 +441,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -467,7 +474,9 @@ async def test_discovery_match_by_name_connectable_false( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", False @@ -477,7 +486,7 @@ async def test_discovery_match_by_name_connectable_false( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 qingping_device = BLEDevice("44:44:33:11:23:45", "Qingping Motion & Light") - qingping_adv = AdvertisementData( + qingping_adv = generate_advertisement_data( local_name="Qingping Motion & Light", service_data={ "0000fdcd-0000-1000-8000-00805f9b34fb": b"H\x12\xcd\xd5`4-X\x08\x04\x01\xe8\x00\x00\x0f\x01{" @@ -493,8 +502,20 @@ async def test_discovery_match_by_name_connectable_false( mock_config_flow.reset_mock() # Make sure it will also take a connectable device + qingping_adv_with_better_rssi = generate_advertisement_data( + local_name="Qingping Motion & Light", + service_data={ + "0000fdcd-0000-1000-8000-00805f9b34fb": b"H\x12\xcd\xd5`4-X\x08\x04\x01\xe8\x00\x00\x0f\x02{" + }, + rssi=-30, + ) inject_advertisement_with_time_and_source_connectable( - hass, qingping_device, qingping_adv, time.monotonic(), "any", True + hass, + qingping_device, + qingping_adv_with_better_rssi, + time.monotonic(), + "any", + True, ) await hass.async_block_till_done() assert _domains_from_mock_config_flow(mock_config_flow) == ["qingping"] @@ -517,7 +538,9 @@ async def test_discovery_match_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) await hass.async_block_till_done() @@ -525,7 +548,7 @@ async def test_discovery_match_by_local_name( assert len(mock_config_flow.mock_calls) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -559,12 +582,12 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_bleak_scanner_start.mock_calls) == 1 hkc_device = BLEDevice("44:44:33:11:23:45", "lock") - hkc_adv_no_mfr_data = AdvertisementData( + hkc_adv_no_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, ) - hkc_adv = AdvertisementData( + hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x06\x02\x03\x99"}, @@ -593,7 +616,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( mock_config_flow.reset_mock() not_hkc_device = BLEDevice("44:44:33:11:23:21", "lock") - not_hkc_adv = AdvertisementData( + not_hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) @@ -602,7 +625,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_config_flow.mock_calls) == 0 not_apple_device = BLEDevice("44:44:33:11:23:23", "lock") - not_apple_adv = AdvertisementData( + not_apple_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) @@ -642,36 +665,38 @@ async def test_discovery_match_by_service_data_uuid_then_others( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_without_service_data_uuid = AdvertisementData( + adv_without_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, ) - adv_with_mfr_data = AdvertisementData( + adv_with_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={323: b"\x01\x02\x03"}, service_data={}, ) - adv_with_service_data_uuid = AdvertisementData( + adv_with_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, ) - adv_with_service_data_uuid_and_mfr_data = AdvertisementData( + adv_with_service_data_uuid_and_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={323: b"\x01\x02\x03"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, ) - adv_with_service_data_uuid_and_mfr_data_and_service_uuid = AdvertisementData( - local_name="lock", - manufacturer_data={323: b"\x01\x02\x03"}, - service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, - service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fd"], + adv_with_service_data_uuid_and_mfr_data_and_service_uuid = ( + generate_advertisement_data( + local_name="lock", + manufacturer_data={323: b"\x01\x02\x03"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, + service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fd"], + ) ) - adv_with_service_uuid = AdvertisementData( + adv_with_service_uuid = generate_advertisement_data( local_name="lock", manufacturer_data={}, service_data={}, @@ -790,18 +815,18 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_without_service_data_uuid = AdvertisementData( + adv_without_service_data_uuid = generate_advertisement_data( local_name="Qingping Temp RH M", service_uuids=[], manufacturer_data={}, ) - xiaomi_format_adv = AdvertisementData( + xiaomi_format_adv = generate_advertisement_data( local_name="Qingping Temp RH M", service_data={ "0000fe95-0000-1000-8000-00805f9b34fb": b"0XH\x0b\x06\xa7%\x144-X\x08" }, ) - qingping_format_adv = AdvertisementData( + qingping_format_adv = generate_advertisement_data( local_name="Qingping Temp RH M", service_data={ "0000fdcd-0000-1000-8000-00805f9b34fb": b"\x08\x16\xa7%\x144-X\x01\x04\xdb\x00\xa6\x01\x02\x01d" @@ -871,12 +896,12 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_service_uuids = AdvertisementData( + adv_service_uuids = generate_advertisement_data( local_name="lock", service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fc"], manufacturer_data={}, ) - adv_manufacturer_data = AdvertisementData( + adv_manufacturer_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x06\x02\x03\x99"}, @@ -924,10 +949,10 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth): assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={1: b"\x01"}, @@ -958,8 +983,8 @@ async def test_async_discovered_device_api( with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt ), patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") @@ -974,10 +999,14 @@ async def test_async_discovered_device_api( assert not bluetooth.async_discovered_service_info(hass) wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[] + ) inject_advertisement(hass, switchbot_device, switchbot_adv) wrong_device_went_unavailable = False switchbot_device_went_unavailable = False @@ -1060,6 +1089,16 @@ def _fake_subscriber( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + seen_switchbot_device = BLEDevice("44:44:33:11:23:46", "wohand") + seen_switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + ) + + inject_advertisement(hass, seen_switchbot_device, seen_switchbot_adv) + cancel = bluetooth.async_register_callback( hass, _fake_subscriber, @@ -1070,7 +1109,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1080,13 +1119,13 @@ def _fake_subscriber( inject_advertisement(hass, switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1096,7 +1135,7 @@ def _fake_subscriber( inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - assert len(callbacks) == 1 + assert len(callbacks) == 2 service_info: BluetoothServiceInfo = callbacks[0][0] assert service_info.name == "wohand" @@ -1138,7 +1177,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1197,7 +1236,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1207,13 +1246,13 @@ def _fake_subscriber( inject_advertisement(hass, switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") # 3rd callback raises ValueError but is still tracked inject_advertisement(hass, empty_device, empty_adv) @@ -1299,18 +1338,29 @@ def _fake_non_connectable_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - + switchbot_adv_better_rssi = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + rssi=-30, + ) inject_advertisement_with_time_and_source_connectable( hass, switchbot_device, switchbot_adv, time.monotonic(), "test", False ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device, switchbot_adv, time.monotonic(), "test", True + hass, + switchbot_device, + switchbot_adv_better_rssi, + time.monotonic(), + "test", + True, ) cancel() @@ -1354,7 +1404,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1362,7 +1412,7 @@ def _fake_subscriber( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1409,7 +1459,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={7676: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1417,7 +1467,7 @@ def _fake_subscriber( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1464,7 +1514,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 ibeacon_device = BLEDevice("44:44:33:11:23:45", "rtx") - ibeacon_adv = AdvertisementData( + ibeacon_adv = generate_advertisement_data( local_name="ibeacon", manufacturer_data={76: b"\x02\x00\x00\x00"}, ) @@ -1472,7 +1522,7 @@ def _fake_subscriber( inject_advertisement(hass, ibeacon_device, ibeacon_adv) homekit_device = BLEDevice("44:44:33:11:23:46", "rtx") - homekit_adv = AdvertisementData( + homekit_adv = generate_advertisement_data( local_name="homekit", manufacturer_data={76: b"\x06\x00\x00\x00"}, ) @@ -1480,7 +1530,7 @@ def _fake_subscriber( inject_advertisement(hass, homekit_device, homekit_adv) apple_device = BLEDevice("44:44:33:11:23:47", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={76: b"\x10\x00\x00\x00"}, ) @@ -1524,7 +1574,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="noisy", manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1532,7 +1582,7 @@ def _fake_subscriber( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1574,7 +1624,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1628,7 +1678,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv = AdvertisementData( + rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1636,7 +1686,7 @@ def _fake_subscriber( inject_advertisement(hass, rtx_device, rtx_adv) yale_device = BLEDevice("44:44:33:11:23:45", "apple") - yale_adv = AdvertisementData( + yale_adv = generate_advertisement_data( local_name="yale", manufacturer_data={465: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1645,7 +1695,7 @@ def _fake_subscriber( await hass.async_block_till_done() other_apple_device = BLEDevice("44:44:33:11:23:22", "apple") - other_apple_adv = AdvertisementData( + other_apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1696,7 +1746,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="switchbot", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) @@ -1704,7 +1754,7 @@ def _fake_subscriber( inject_advertisement(hass, switchbot_dev, switchbot_adv) switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_missing_service_uuid_adv = AdvertisementData( + switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1714,7 +1764,7 @@ def _fake_subscriber( await hass.async_block_till_done() service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") - service_uuid_wrong_address_adv = AdvertisementData( + service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) @@ -1765,7 +1815,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="switchbot", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, ) @@ -1773,7 +1823,7 @@ def _fake_subscriber( inject_advertisement(hass, switchbot_dev, switchbot_adv) switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_missing_service_uuid_adv = AdvertisementData( + switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1783,7 +1833,7 @@ def _fake_subscriber( await hass.async_block_till_done() service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") - service_uuid_wrong_address_adv = AdvertisementData( + service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, ) @@ -1831,7 +1881,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv = AdvertisementData( + rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1839,12 +1889,12 @@ def _fake_subscriber( inject_advertisement(hass, rtx_device, rtx_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) rtx_device_2 = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv_2 = AdvertisementData( + rtx_adv_2 = generate_advertisement_data( local_name="rtx2", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1927,7 +1977,7 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "xiaomi") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="xiaomi", service_data={ "0000fe95-0000-1000-8000-00805f9b34fb": b"\xd8.\xad\xcd\r\x85" @@ -1937,7 +1987,7 @@ def _fake_subscriber( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1981,13 +2031,13 @@ def _fake_subscriber( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, @@ -2035,7 +2085,7 @@ def _callback(service_info: BluetoothServiceInfo) -> bool: while not done.done(): device = BLEDevice("aa:44:33:11:23:45", "wohand") - adv = AdvertisementData( + adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -2060,13 +2110,13 @@ async def test_process_advertisements_ignore_bad_advertisement( return_value = asyncio.Event() device = BLEDevice("aa:44:33:11:23:45", "wohand") - adv = AdvertisementData( + adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fa": b""}, ) - adv2 = AdvertisementData( + adv2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, @@ -2142,20 +2192,20 @@ def _device_detected( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( @@ -2214,20 +2264,20 @@ def _device_detected( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( @@ -2272,7 +2322,7 @@ def _device_detected( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -2313,20 +2363,20 @@ def _device_detected( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() @@ -2368,20 +2418,20 @@ def _device_detected( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:42", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:62", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() @@ -2434,8 +2484,8 @@ async def test_async_ble_device_from_address( with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt ), patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") @@ -2453,7 +2503,9 @@ async def test_async_ble_device_from_address( assert not bluetooth.async_discovered_service_info(hass) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[] + ) inject_advertisement(hass, switchbot_device, switchbot_adv) await hass.async_block_till_done() @@ -2595,7 +2647,7 @@ async def test_getting_the_scanner_returns_the_wrapped_instance(hass, enable_blu async def test_scanner_count_connectable(hass, enable_bluetooth): """Test getting the connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2603,7 +2655,7 @@ async def test_scanner_count_connectable(hass, enable_bluetooth): async def test_scanner_count(hass, enable_bluetooth): """Test getting the connectable and non-connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "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 f3f3d1b366466a..0375f68309f717 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,29 +1,57 @@ """Tests for the Bluetooth integration manager.""" +import time from unittest.mock import AsyncMock, MagicMock, patch -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from bluetooth_adapters import AdvertisementHistory +import pytest from homeassistant.components import bluetooth -from homeassistant.components.bluetooth.manager import STALE_ADVERTISEMENT_SECONDS +from homeassistant.components.bluetooth import models +from homeassistant.components.bluetooth.manager import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + generate_advertisement_data, inject_advertisement_with_source, inject_advertisement_with_time_and_source, + inject_advertisement_with_time_and_source_connectable, ) +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> None: + """Register an hci0 scanner.""" + cancel = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci0"), True + ) + yield + cancel() + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> None: + """Register an hci1 scanner.""" + cancel = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci1"), True + ) + yield + cancel() + + async def test_advertisements_do_not_switch_adapters_for_no_reason( - hass, enable_bluetooth + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner ): """Test we only switch adapters when needed.""" address = "44:44:33:11:23:12" switchbot_device_signal_100 = BLEDevice(address, "wohand_signal_100", rssi=-100) - switchbot_adv_signal_100 = AdvertisementData( + switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( @@ -36,7 +64,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) switchbot_device_signal_99 = BLEDevice(address, "wohand_signal_99", rssi=-99) - switchbot_adv_signal_99 = AdvertisementData( + switchbot_adv_signal_99 = generate_advertisement_data( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( @@ -49,7 +77,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) switchbot_device_signal_98 = BLEDevice(address, "wohand_good_signal", rssi=-98) - switchbot_adv_signal_98 = AdvertisementData( + switchbot_adv_signal_98 = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( @@ -63,14 +91,16 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) -async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): +async def test_switching_adapters_based_on_rssi( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on rssi.""" address = "44:44:33:11:23:45" - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal", rssi=-100) - switchbot_adv_poor_signal = AdvertisementData( - local_name="wohand_poor_signal", service_uuids=[] + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" @@ -81,9 +111,9 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal", rssi=-60) - switchbot_adv_good_signal = AdvertisementData( - local_name="wohand_good_signal", service_uuids=[] + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" @@ -103,11 +133,9 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice( - address, "wohand_similar_signal", rssi=-62 - ) - switchbot_adv_similar_signal = AdvertisementData( - local_name="wohand_similar_signal", service_uuids=[] + switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_adv_similar_signal = generate_advertisement_data( + local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( @@ -119,14 +147,16 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): ) -async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): +async def test_switching_adapters_based_on_zero_rssi( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on zero rssi.""" address = "44:44:33:11:23:45" - switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal", rssi=0) - switchbot_adv_no_rssi = AdvertisementData( - local_name="wohand_no_rssi", service_uuids=[] + switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_no_rssi = generate_advertisement_data( + local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0" @@ -137,9 +167,9 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): is switchbot_device_no_rssi ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal", rssi=-60) - switchbot_adv_good_signal = AdvertisementData( - local_name="wohand_good_signal", service_uuids=[] + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" @@ -159,11 +189,9 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice( - address, "wohand_similar_signal", rssi=-62 - ) - switchbot_adv_similar_signal = AdvertisementData( - local_name="wohand_similar_signal", service_uuids=[] + switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_adv_similar_signal = generate_advertisement_data( + local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( @@ -175,17 +203,17 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): ) -async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): +async def test_switching_adapters_based_on_stale( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on the previous advertisement being stale.""" address = "44:44:33:11:23:41" start_time_monotonic = 50.0 - switchbot_device_poor_signal_hci0 = BLEDevice( - address, "wohand_poor_signal_hci0", rssi=-100 - ) - switchbot_adv_poor_signal_hci0 = AdvertisementData( - local_name="wohand_poor_signal_hci0", service_uuids=[] + switchbot_device_poor_signal_hci0 = BLEDevice(address, "wohand_poor_signal_hci0") + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source( hass, @@ -200,11 +228,9 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): is switchbot_device_poor_signal_hci0 ) - switchbot_device_poor_signal_hci1 = BLEDevice( - address, "wohand_poor_signal_hci1", rssi=-99 - ) - switchbot_adv_poor_signal_hci1 = AdvertisementData( - local_name="wohand_poor_signal_hci1", service_uuids=[] + switchbot_device_poor_signal_hci1 = BLEDevice(address, "wohand_poor_signal_hci1") + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 ) inject_advertisement_with_time_and_source( hass, @@ -227,7 +253,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): hass, switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, - start_time_monotonic + STALE_ADVERTISEMENT_SECONDS + 1, + start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1, "hci1", ) @@ -244,7 +270,7 @@ async def test_restore_history_from_dbus(hass, one_adapter): ble_device = BLEDevice(address, "name") history = { address: AdvertisementHistory( - ble_device, AdvertisementData(local_name="name"), "hci0" + ble_device, generate_advertisement_data(local_name="name"), "hci0" ) } @@ -256,3 +282,185 @@ async def test_restore_history_from_dbus(hass, one_adapter): await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + + +async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): + """Test switching adapters based on rssi from connectable to non connectable.""" + + address = "44:44:33:11:23:45" + now = time.monotonic() + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source_connectable( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_poor_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + now, + "hci1", + False, + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_poor_signal, + now, + "hci0", + False, + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + switchbot_device_excellent_signal = BLEDevice(address, "wohand_excellent_signal") + switchbot_adv_excellent_signal = generate_advertisement_data( + local_name="wohand_excellent_signal", service_uuids=[], rssi=-25 + ) + + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_excellent_signal, + switchbot_adv_excellent_signal, + now, + "hci2", + False, + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_excellent_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + + +async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_connectable( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): + """Test we can still get a connectable BLEDevice when the best path is non-connectable. + + In this case the the device is closer to a non-connectable scanner, but the + at least one connectable scanner has the device in range. + """ + + address = "44:44:33:11:23:45" + now = time.monotonic() + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + now, + "hci1", + False, + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert bluetooth.async_ble_device_from_address(hass, address, True) is None + + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source_connectable( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + + +async def test_switching_adapters_when_one_goes_away( + hass, enable_bluetooth, register_hci0_scanner +): + """Test switching adapters when one goes away.""" + cancel_hci2 = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci2"), True + ) + + address = "44:44:33:11:23:45" + + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_source( + hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci2" + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_good_signal + ) + + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_source( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + ) + + # We want to prefer the good signal when we have options + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_good_signal + ) + + cancel_hci2() + + inject_advertisement_with_source( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + ) + + # Now that hci2 is gone, we should prefer the poor signal + # since no poor signal is better than no signal + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal + ) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d126dcac301dd3..adb953b2af2901 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -16,7 +16,12 @@ HaBluetoothConnector, ) -from . import _get_manager, inject_advertisement, inject_advertisement_with_source +from . import ( + _get_manager, + generate_advertisement_data, + inject_advertisement, + inject_advertisement_with_source, +) class MockBleakClient(BleakClient): @@ -49,7 +54,7 @@ async def test_wrapped_bleak_scanner(hass, enable_bluetooth): """Test wrapped bleak scanner dispatches calls as expected.""" scanner = HaBleakScannerWrapper() switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -84,7 +89,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( switchbot_device = BLEDevice( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"} ) - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -116,7 +121,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( }, rssi=-30, ) - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -153,6 +158,11 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab ), "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, + ) + switchbot_proxy_device_adv_no_connection_slot = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, rssi=-30, ) switchbot_proxy_device_has_connection_slot = BLEDevice( @@ -166,14 +176,19 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab }, rssi=-40, ) + switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-40, + ) switchbot_device = BLEDevice( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, - rssi=-100, ) - switchbot_adv = AdvertisementData( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) inject_advertisement_with_source( @@ -182,21 +197,28 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab inject_advertisement_with_source( hass, switchbot_proxy_device_has_connection_slot, - switchbot_adv, + switchbot_proxy_device_adv_has_connection_slot, "esp32_has_connection_slot", ) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, - switchbot_adv, + switchbot_proxy_device_adv_no_connection_slot, "esp32_no_connection_slot", ) class FakeScanner(BaseHaScanner): @property - def discovered_devices(self) -> list[BLEDevice]: + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices.""" - return [switchbot_proxy_device_has_connection_slot] + return { + switchbot_proxy_device_has_connection_slot.address: ( + switchbot_proxy_device_has_connection_slot, + switchbot_proxy_device_adv_has_connection_slot, + ) + } async def async_get_device_by_address(self, address: str) -> BLEDevice | None: """Return a list of discovered devices.""" @@ -204,7 +226,7 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot @@ -237,7 +259,12 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-30, ) switchbot_proxy_device_no_connection_slot.metadata["delegate"] = 0 - + switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-30, + ) switchbot_proxy_device_has_connection_slot = BLEDevice( "44:44:33:11:23:45", "wohand_has_connection_slot", @@ -247,9 +274,14 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab ), "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_has_connection_slot.metadata["delegate"] = 0 + switchbot_proxy_device_has_connection_slot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-40, + ) switchbot_device = BLEDevice( "44:44:33:11:23:45", @@ -258,31 +290,41 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-100, ) switchbot_device.metadata["delegate"] = 0 - switchbot_adv = AdvertisementData( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, ) inject_advertisement_with_source( - hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" + hass, switchbot_device, switchbot_device_adv, "00:00:00:00:00:01" ) inject_advertisement_with_source( hass, switchbot_proxy_device_has_connection_slot, - switchbot_adv, + switchbot_proxy_device_has_connection_slot_adv, "esp32_has_connection_slot", ) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, - switchbot_adv, + switchbot_proxy_device_no_connection_slot_adv, "esp32_no_connection_slot", ) class FakeScanner(BaseHaScanner): @property - def discovered_devices(self) -> list[BLEDevice]: + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices.""" - return [switchbot_proxy_device_has_connection_slot] + return { + switchbot_proxy_device_has_connection_slot.address: ( + switchbot_proxy_device_has_connection_slot, + switchbot_proxy_device_has_connection_slot_adv, + ) + } async def async_get_device_by_address(self, address: str) -> BLEDevice | None: """Return a list of discovered devices.""" @@ -290,7 +332,7 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index b8ade8c39f9af7..fb80bb7cec4f99 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -127,8 +127,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( ): """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" with patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 0ca5f299a50b62..e72efd565deb02 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -201,8 +201,8 @@ async def test_unavailable_after_no_data( ): """Test that the coordinator is unavailable after no data for a while.""" with patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index a4666352479162..c3a08ac3361b1b 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -1,14 +1,11 @@ """Tests for the Bluetooth integration scanners.""" +import asyncio from datetime import timedelta import time from unittest.mock import MagicMock, patch from bleak import BleakError -from bleak.backends.scanner import ( - AdvertisementData, - AdvertisementDataCallback, - BLEDevice, -) +from bleak.backends.scanner import AdvertisementDataCallback, BLEDevice from dbus_fast import InvalidMessageError import pytest @@ -22,7 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.util import dt as dt_util -from . import _get_manager, async_setup_with_one_adapter +from . import _get_manager, async_setup_with_one_adapter, generate_advertisement_data from tests.common import async_fire_time_changed @@ -222,7 +219,7 @@ def discovered_devices(self): ): _callback( BLEDevice("44:44:33:11:23:42", "any_name"), - AdvertisementData(local_name="any_name"), + generate_advertisement_data(local_name="any_name"), ) # Ensure we don't restart the scanner if we don't need to @@ -491,3 +488,68 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text + + +async def test_restart_takes_longer_than_watchdog_time(hass, one_adapter, caplog): + """Test we do not try to recover the adapter again if the restart is still in progress.""" + + release_start_event = asyncio.Event() + called_start = 0 + + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + return + await release_start_event.wait() + + async def stop(self, *args, **kwargs): + """Mock Start.""" + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + return [] + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with patch( + "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic, + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), patch( + "homeassistant.components.bluetooth.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.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ): + async_fire_time_changed( + hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + ) + await asyncio.sleep(0) + + # Now release the start event + release_start_event.set() + await hass.async_block_till_done() + + assert "already restarting" in caplog.text diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index 36ed6abdde5a93..585c83f20a7747 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -5,7 +5,7 @@ from unittest.mock import patch from bleak import BleakError -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth_le_tracker import device_tracker @@ -23,6 +23,7 @@ from homeassistant.util import dt as dt_util, slugify from tests.common import async_fire_time_changed +from tests.components.bluetooth import generate_advertisement_data class MockBleakClient: @@ -89,7 +90,7 @@ async def test_preserve_new_tracked_device_name( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -114,7 +115,7 @@ async def test_preserve_new_tracked_device_name( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -158,7 +159,7 @@ async def test_tracking_battery_times_out( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -224,7 +225,7 @@ async def test_tracking_battery_fails(hass, mock_bluetooth, mock_device_tracker_ service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -292,7 +293,7 @@ async def test_tracking_battery_successful( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=True, ) diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 90b3f725a6dc6d..f1b0011656f09c 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -3,8 +3,8 @@ from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( - IMPERIAL_SYSTEM as IMPERIAL, METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, UnitSystem, ) diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 64986e9d973f71..58e684a1378a0e 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -1,17 +1,27 @@ """Define tests for the Bravia TV config flow.""" from unittest.mock import patch -from pybravia import BraviaTVAuthError, BraviaTVConnectionError, BraviaTVNotSupported +from pybravia import ( + BraviaTVAuthError, + BraviaTVConnectionError, + BraviaTVError, + BraviaTVNotSupported, +) +import pytest from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( + CONF_CLIENT_ID, CONF_IGNORED_SOURCES, + CONF_NICKNAME, CONF_USE_PSK, DOMAIN, + NICKNAME_PREFIX, ) -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.helpers import instance_id from tests.common import MockConfigEntry @@ -87,6 +97,7 @@ async def test_show_form(hass): async def test_ssdp_discovery(hass): """Test that the device is discovered.""" + uuid = await instance_id.async_get(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -123,6 +134,8 @@ async def test_ssdp_discovery(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } @@ -222,12 +235,13 @@ async def test_authorize_model_unsupported(hass): async def test_authorize_no_ip_control(hass): """Test that errors are shown when IP Control is disabled on the TV.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} - ) + with patch("pybravia.BraviaTV.pair", side_effect=BraviaTVError): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "no_ip_control" + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_ip_control" async def test_duplicate_error(hass): @@ -263,6 +277,8 @@ async def test_duplicate_error(hass): async def test_create_entry(hass): """Test that the user step works.""" + uuid = await instance_id.async_get(hass) + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( "pybravia.BraviaTV.set_wol_mode" ), patch( @@ -290,11 +306,15 @@ async def test_create_entry(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } async def test_create_entry_with_ipv6_address(hass): """Test that the user step works with device IPv6 address.""" + uuid = await instance_id.async_get(hass) + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( "pybravia.BraviaTV.set_wol_mode" ), patch( @@ -324,6 +344,8 @@ async def test_create_entry_with_ipv6_address(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } @@ -398,3 +420,110 @@ async def test_options_flow(hass): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == {CONF_IGNORED_SOURCES: ["HDMI 1", "HDMI 2"]} + + +@pytest.mark.parametrize( + "user_input", + [{CONF_PIN: "mypsk", CONF_USE_PSK: True}, {CONF_PIN: "1234", CONF_USE_PSK: False}], +) +async def test_reauth_successful(hass, user_input): + """Test starting a reauthentication flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch("pybravia.BraviaTV.connect"), patch( + "pybravia.BraviaTV.get_power_status", + return_value="active", + ), patch( + "pybravia.BraviaTV.get_external_status", + return_value=BRAVIA_SOURCES, + ), patch( + "pybravia.BraviaTV.send_rest_req", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_unsuccessful(hass): + """Test reauthentication flow failed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch( + "pybravia.BraviaTV.connect", + side_effect=BraviaTVAuthError, + ), patch("pybravia.BraviaTV.pair"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "mypsk", CONF_USE_PSK: True}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" + + +async def test_reauth_unsuccessful_during_pairing(hass): + """Test reauthentication flow failed because of pairing error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch("pybravia.BraviaTV.pair", side_effect=BraviaTVError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" diff --git a/tests/components/broadlink/__init__.py b/tests/components/broadlink/__init__.py index ce7e79bdff6420..8cdb4f478a3d99 100644 --- a/tests/components/broadlink/__init__.py +++ b/tests/components/broadlink/__init__.py @@ -78,6 +78,16 @@ 57, 5, ), + "Gaming room": ( + "192.168.0.65", + "34ea34b61d2d", + "MP1-1K4S", + "Broadlink", + "MP1", + 0x4EB5, + 57, + 5, + ), } diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py index 5430af9e311f6c..c50494a9b840e8 100644 --- a/tests/components/broadlink/test_device.py +++ b/tests/components/broadlink/test_device.py @@ -6,6 +6,7 @@ from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.device import get_domains from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.helpers.entity_registry import async_entries_for_device from . import get_device @@ -266,7 +267,11 @@ async def test_device_setup_registry(hass): assert device_entry.sw_version == device.fwversion for entry in async_entries_for_device(entity_registry, device_entry.id): - assert entry.original_name.startswith(device.name) + assert ( + hass.states.get(entry.entity_id) + .attributes[ATTR_FRIENDLY_NAME] + .startswith(device.name) + ) async def test_device_unload_works(hass): @@ -345,4 +350,8 @@ async def test_device_update_listener(hass): ) assert device_entry.name == "New Name" for entry in async_entries_for_device(entity_registry, device_entry.id): - assert entry.original_name.startswith("New Name") + assert ( + hass.states.get(entry.entity_id) + .attributes[ATTR_FRIENDLY_NAME] + .startswith("New Name") + ) diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index a3b291efd0021b..d4fd9cd75b49ef 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -9,7 +9,7 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.helpers.entity_registry import async_entries_for_device from . import get_device @@ -39,7 +39,10 @@ async def test_remote_setup_works(hass): assert len(remotes) == 1 remote = remotes[0] - assert remote.original_name == f"{device.name} Remote" + assert ( + hass.states.get(remote.entity_id).attributes[ATTR_FRIENDLY_NAME] + == device.name + ) assert hass.states.get(remote.entity_id).state == STATE_ON assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index b5a49fdae15e6b..13190883ef0978 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -3,7 +3,7 @@ from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.updater import BroadlinkSP4UpdateManager -from homeassistant.const import Platform +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.util import dt @@ -39,13 +39,16 @@ async def test_a1_sensor_setup(hass): assert len(sensors) == 5 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { (f"{device.name} Temperature", "27.4"), (f"{device.name} Humidity", "59.3"), - (f"{device.name} Air Quality", "3"), + (f"{device.name} Air quality", "3"), (f"{device.name} Light", "2"), (f"{device.name} Noise", "1"), } @@ -86,13 +89,16 @@ async def test_a1_sensor_update(hass): assert mock_setup.api.check_sensors_raw.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { (f"{device.name} Temperature", "22.5"), (f"{device.name} Humidity", "47.4"), - (f"{device.name} Air Quality", "2"), + (f"{device.name} Air quality", "2"), (f"{device.name} Light", "3"), (f"{device.name} Noise", "2"), } @@ -118,7 +124,10 @@ async def test_rm_pro_sensor_setup(hass): assert len(sensors) == 1 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "18.2")} @@ -147,7 +156,10 @@ async def test_rm_pro_sensor_update(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "25.8")} @@ -179,7 +191,10 @@ async def test_rm_pro_filter_crazy_temperature(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "22.9")} @@ -225,7 +240,10 @@ async def test_rm4_pro_hts2_sensor_setup(hass): assert len(sensors) == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -257,7 +275,10 @@ async def test_rm4_pro_hts2_sensor_update(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -316,7 +337,10 @@ async def test_scb1e_sensor_setup(hass): assert len(sensors) == 5 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -378,7 +402,10 @@ async def test_scb1e_sensor_update(hass): assert mock_setup.api.get_state.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py new file mode 100644 index 00000000000000..9a7fc7e1ec9b7f --- /dev/null +++ b/tests/components/broadlink/test_switch.py @@ -0,0 +1,126 @@ +"""Tests for Broadlink switches.""" +from homeassistant.components.broadlink.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.helpers.entity_registry import async_entries_for_device + +from . import get_device + +from tests.common import mock_device_registry, mock_registry + + +async def test_switch_setup_works(hass): + """Test a successful setup with a switch.""" + device = get_device("Dining room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 1 + + switch = switches[0] + assert ( + hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] == device.name + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + assert mock_setup.api.auth.call_count == 1 + + +async def test_switch_turn_off_turn_on(hass): + """Test send turn on and off for a switch.""" + device = get_device("Dining room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 1 + + switch = switches[0] + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_ON + + assert mock_setup.api.auth.call_count == 1 + + +async def test_slots_switch_setup_works(hass): + """Test a successful setup with a switch with slots.""" + device = get_device("Gaming room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 4 + + for slot, switch in enumerate(switches): + assert ( + hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] + == f"{device.name} S{slot+1}" + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + assert mock_setup.api.auth.call_count == 1 + + +async def test_slots_switch_turn_off_turn_on(hass): + """Test send turn on and off for a switch with slots.""" + device = get_device("Gaming room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 4 + + for switch in switches: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_ON + + assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 9212e12e5b3dfb..58ccecaf29fe34 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -113,8 +113,6 @@ async def test_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hl_l2340dw_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 11014 - assert state.attributes.get(ATTR_COUNTER) == 986 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -123,11 +121,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "11014" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "986" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + assert entry + assert entry.unique_id == "0123456789_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -136,11 +154,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_black_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + assert entry + assert entry.unique_id == "0123456789_black_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -149,11 +187,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + assert entry + assert entry.unique_id == "0123456789_cyan_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -162,11 +220,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + assert entry + assert entry.unique_id == "0123456789_magenta_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -175,6 +253,28 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + assert entry + assert entry.unique_id == "0123456789_yellow_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water-outline" diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py index f2e88d97ba2e16..d233fa068ea95e 100644 --- a/tests/components/bsblan/__init__.py +++ b/tests/components/bsblan/__init__.py @@ -1,88 +1 @@ """Tests for the bsblan integration.""" - -from homeassistant.components.bsblan.const import ( - CONF_DEVICE_IDENT, - CONF_PASSKEY, - DOMAIN, -) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONTENT_TYPE_JSON, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - - -async def init_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the BSBLan integration in Home Assistant.""" - - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - params={"Parameter": "6224,6225,6226"}, - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="RVS21.831F/127", - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - CONF_DEVICE_IDENT: "RVS21.831F/127", - }, - ) - - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry - - -async def init_integration_without_auth( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the BSBLan integration in Home Assistant.""" - - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - params={"Parameter": "6224,6225,6226"}, - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="RVS21.831F/127", - data={ - CONF_HOST: "example.local", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - CONF_DEVICE_IDENT: "RVS21.831F/127", - }, - ) - - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py new file mode 100644 index 00000000000000..44d87745b3f101 --- /dev/null +++ b/tests/components/bsblan/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for BSBLAN integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from bsblan import Device, Info, State +import pytest + +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.bsblan.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_bsblan_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked BSBLAN client.""" + with patch( + "homeassistant.components.bsblan.config_flow.BSBLAN", autospec=True + ) as bsblan_mock: + bsblan = bsblan_mock.return_value + bsblan.device.return_value = Device.parse_raw( + load_fixture("device.json", DOMAIN) + ) + bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + yield bsblan + + +@pytest.fixture +def mock_bsblan(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked BSBLAN client.""" + + with patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock: + bsblan = bsblan_mock.return_value + bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + bsblan.device.return_value = Device.parse_raw( + load_fixture("device.json", DOMAIN) + ) + bsblan.state.return_value = State.parse_raw(load_fixture("state.json", DOMAIN)) + yield bsblan + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_bsblan: MagicMock +) -> MockConfigEntry: + """Set up the bsblan integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/bsblan/fixtures/device.json b/tests/components/bsblan/fixtures/device.json new file mode 100644 index 00000000000000..10543d72253820 --- /dev/null +++ b/tests/components/bsblan/fixtures/device.json @@ -0,0 +1,42 @@ +{ + "name": "BSB-LAN", + "version": "1.0.38-20200730234859", + "freeram": 85479, + "uptime": 969402857, + "MAC": "00:80:41:19:69:90", + "freespace": 0, + "bus": "BSB", + "buswritable": 1, + "busaddr": 66, + "busdest": 0, + "monitor": 0, + "verbose": 1, + "protectedGPIO": [ + { "pin": 0 }, + { "pin": 1 }, + { "pin": 4 }, + { "pin": 10 }, + { "pin": 11 }, + { "pin": 12 }, + { "pin": 13 }, + { "pin": 18 }, + { "pin": 19 }, + { "pin": 20 }, + { "pin": 21 }, + { "pin": 22 }, + { "pin": 23 }, + { "pin": 50 }, + { "pin": 51 }, + { "pin": 52 }, + { "pin": 53 }, + { "pin": 62 }, + { "pin": 63 }, + { "pin": 64 }, + { "pin": 65 }, + { "pin": 66 }, + { "pin": 67 }, + { "pin": 68 }, + { "pin": 69 } + ], + "averages": [] +} diff --git a/tests/components/bsblan/fixtures/info.json b/tests/components/bsblan/fixtures/info.json index 08ae7e462472f1..556c7463e173c8 100644 --- a/tests/components/bsblan/fixtures/info.json +++ b/tests/components/bsblan/fixtures/info.json @@ -1,23 +1,29 @@ { - "6224": { - "name": "Geräte-Identifikation", + "device_identification": { + "name": "Gerte-Identifikation", + "error": 0, "value": "RVS21.831F/127", - "unit": "", "desc": "", - "dataType": 7 + "dataType": 7, + "readonly": 0, + "unit": "" }, - "6225": { + "controller_family": { "name": "Device family", + "error": 0, "value": "211", - "unit": "", "desc": "", - "dataType": 0 + "dataType": 0, + "readonly": 0, + "unit": "" }, - "6226": { + "controller_variant": { "name": "Device variant", + "error": 0, "value": "127", - "unit": "", "desc": "", - "dataType": 0 + "dataType": 0, + "readonly": 0, + "unit": "" } } diff --git a/tests/components/bsblan/fixtures/state.json b/tests/components/bsblan/fixtures/state.json new file mode 100644 index 00000000000000..51d4cf2e136eeb --- /dev/null +++ b/tests/components/bsblan/fixtures/state.json @@ -0,0 +1,101 @@ +{ + "hvac_mode": { + "name": "Operating mode", + "error": 0, + "value": "heat", + "desc": "Komfort", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "target_temperature": { + "name": "Room temperature Comfort setpoint", + "error": 0, + "value": "18.5", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "target_temperature_high": { + "name": "Komfortsollwert Maximum", + "error": 0, + "value": "23.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "target_temperature_low": { + "name": "Room temp reduced setpoint", + "error": 0, + "value": "17.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "min_temp": { + "name": "Room temp frost protection setpoint", + "error": 0, + "value": "8.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "max_temp": { + "name": "Summer/winter changeover temp heat circuit 1", + "error": 0, + "value": "20.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "hvac_mode2": { + "name": "Operating mode", + "error": 0, + "value": "2", + "desc": "Reduziert", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "hvac_action": { + "name": "Status heating circuit 1", + "error": 0, + "value": "122", + "desc": "Raumtemp\u2019begrenzung", + "dataType": 1, + "readonly": 1, + "unit": "" + }, + "outside_temperature": { + "name": "Outside temp sensor local", + "error": 0, + "value": "6.1", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "current_temperature": { + "name": "Room temp 1 actual value", + "error": 0, + "value": "18.6", + "desc": "", + "dataType": 0, + "readonly": 1, + "unit": "°C" + }, + "room1_thermostat_mode": { + "name": "Raumthermostat 1", + "error": 0, + "value": "0", + "desc": "Kein Bedarf", + "dataType": 1, + "readonly": 1, + "unit": "" + } +} diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index b8efa960fcabd9..a1286f436952c7 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,159 +1,119 @@ """Tests for the BSBLan device config flow.""" -import aiohttp +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANConnectionError from homeassistant import data_entry_flow from homeassistant.components.bsblan import config_flow -from homeassistant.components.bsblan.const import CONF_DEVICE_IDENT, CONF_PASSKEY +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONTENT_TYPE_JSON, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers.device_registry import format_mac -from . import init_integration - -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_bsblan_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - -async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we show user form on BSBLan connection error.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["errors"] == {"base": "cannot_connect"} - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == format_mac("00:80:41:19:69:90") + assert result2.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + } + assert "result" in result2 + assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan_config_flow.device.mock_calls) == 1 -async def test_user_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort zeroconf flow if BSBLan device already configured.""" - await init_integration(hass, aioclient_mock) +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - }, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -async def test_full_user_flow_implementation( - hass: HomeAssistant, aioclient_mock +async def test_connection_error( + hass: HomeAssistant, + mock_bsblan_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) + """Test we show user form on BSBLan connection error.""" + mock_bsblan_config_flow.device.side_effect = BSBLANConnectionError result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", + data={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["data"][CONF_HOST] == "example.local" - assert result["data"][CONF_USERNAME] == "nobody" - assert result["data"][CONF_PASSWORD] == "qwerty" - assert result["data"][CONF_PASSKEY] == "1234" - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_DEVICE_IDENT] == "RVS21.831F/127" - assert result["title"] == "RVS21.831F/127" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + assert result.get("step_id") == "user" - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].unique_id == "RVS21.831F/127" - -async def test_full_user_flow_implementation_without_auth( - hass: HomeAssistant, aioclient_mock +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_bsblan_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.post( - "http://example2.local:80/JQ?Parameter=6224,6225,6226", - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - + """Test we abort flow if BSBLAN device already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "example2.local", + data={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["data"][CONF_HOST] == "example2.local" - assert result["data"][CONF_USERNAME] is None - assert result["data"][CONF_PASSWORD] is None - assert result["data"][CONF_PASSKEY] is None - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_DEVICE_IDENT] == "RVS21.831F/127" - assert result["title"] == "RVS21.831F/127" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].unique_id == "RVS21.831F/127" + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 147ba46cb5b5c3..34ee30a35e1805 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,48 +1,46 @@ """Tests for the BSBLan integration.""" -import aiohttp +from unittest.mock import MagicMock + +from bsblan import BSBLANConnectionError from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import init_integration, init_integration_without_auth - -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry -async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, ) -> None: - """Test the BSBLan configuration entry not ready.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) - - entry = await init_integration(hass, aioclient_mock) - assert entry.state is ConfigEntryState.SETUP_RETRY - + """Test the BSBLAN configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() -async def test_unload_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the BSBLan configuration entry unloading.""" - entry = await init_integration(hass, aioclient_mock) - assert hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_bsblan.device.mock_calls) == 1 - await hass.config_entries.async_unload(entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_config_entry_no_authentication( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, ) -> None: - """Test the BSBLan configuration entry not ready.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) - - entry = await init_integration_without_auth(hass, aioclient_mock) - assert entry.state is ConfigEntryState.SETUP_RETRY + """Test the bsblan configuration entry not ready.""" + mock_bsblan.state.side_effect = BSBLANConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_bsblan.state.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/bthome/__init__.py b/tests/components/bthome/__init__.py index e480c0a38103c1..25ccb72edfad25 100644 --- a/tests/components/bthome/__init__.py +++ b/tests/components/bthome/__init__.py @@ -1,10 +1,11 @@ """Tests for the BTHome integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( name="ATC 8D18B2", address="A4:C1:38:8D:18:B2", @@ -16,7 +17,7 @@ }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -32,7 +33,7 @@ }, service_uuids=["0000181e-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -48,7 +49,7 @@ }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="prst"), + advertisement=generate_advertisement_data(local_name="prst"), time=0, connectable=False, ) @@ -64,7 +65,7 @@ }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -78,7 +79,7 @@ service_data={}, service_uuids=[], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -97,7 +98,7 @@ def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBlea }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Test Device"), + advertisement=generate_advertisement_data(local_name="Test Device"), time=0, connectable=False, ) @@ -118,7 +119,7 @@ def make_encrypted_advertisement( }, service_uuids=["0000181e-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="ATC 8F80A5"), + advertisement=generate_advertisement_data(local_name="ATC 8F80A5"), time=0, connectable=False, ) diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 0fbcaf38432232..85e32155723a9a 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -13,7 +13,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_events_http_api(hass, recorder_mock): +async def test_events_http_api(recorder_mock, hass): """Test the calendar demo view.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index ebe5e9185e4076..24b4b06b493960 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -62,14 +62,15 @@ def create_event( self, start: datetime.timedelta, end: datetime.timedelta, - description: str = None, - location: str = None, + summary: str | None = None, + description: str | None = None, + location: str | None = None, ) -> dict[str, Any]: """Create a new fake event, used by tests.""" event = calendar.CalendarEvent( start=start, end=end, - summary=f"Event {secrets.token_hex(16)}", # Arbitrary unique data + summary=summary if summary else f"Event {secrets.token_hex(16)}", description=description, location=location, ) @@ -85,8 +86,13 @@ async def async_get_events( """Get all events in a specific time frame, used by the demo calendar.""" assert start_date < end_date values = [] + local_start_date = dt_util.as_local(start_date) + local_end_date = dt_util.as_local(end_date) for event in self.events: - if start_date < event.start < end_date or start_date < event.end < end_date: + if ( + event.start_datetime_local < local_end_date + and local_start_date < event.end_datetime_local + ): values.append(event) return values @@ -99,11 +105,28 @@ async def fire_time(self, trigger_time: datetime.datetime) -> None: async def fire_until(self, end: datetime.timedelta) -> None: """Simulate the passage of time by firing alarms until the time is reached.""" + + current_time = dt_util.as_utc(self.freezer()) + if (end - current_time) > (TEST_UPDATE_INTERVAL * 2): + # Jump ahead to right before the target alarm them to remove + # unnecessary waiting, before advancing in smaller increments below. + # This leaves time for multiple update intervals to refresh the set + # of upcoming events + await self.fire_time(end - TEST_UPDATE_INTERVAL * 2) + while dt_util.utcnow() < end: self.freezer.tick(TEST_TIME_ADVANCE_INTERVAL) await self.fire_time(dt_util.utcnow()) +@pytest.fixture +def set_time_zone(hass): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + hass.config.set_time_zone("America/Regina") + + @pytest.fixture def fake_schedule(hass, freezer): """Fixture that tests can use to make fake events.""" @@ -534,25 +557,72 @@ async def test_update_missed(hass, calls, fake_schedule): ] -async def test_event_payload(hass, calls, fake_schedule): - """Test the a calendar trigger based on start time.""" - event_data = fake_schedule.create_event( - start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), - end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), - description="Description", - location="Location", - ) +@pytest.mark.parametrize( + "create_data,fire_time,payload_data", + [ + ( + { + "start": datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + "end": datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + "summary": "Summary", + }, + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + { + "summary": "Summary", + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "all_day": False, + }, + ), + ( + { + "start": datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + "end": datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + "summary": "Summary", + "description": "Description", + "location": "Location", + }, + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + { + "summary": "Summary", + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "all_day": False, + "description": "Description", + "location": "Location", + }, + ), + ( + { + "summary": "Summary", + "start": datetime.date.fromisoformat("2022-04-20"), + "end": datetime.date.fromisoformat("2022-04-21"), + }, + datetime.datetime.fromisoformat("2022-04-20 00:00:01-06:00"), + { + "summary": "Summary", + "start": "2022-04-20", + "end": "2022-04-21", + "all_day": True, + }, + ), + ], + ids=["basic", "more-fields", "all-day"], +) +async def test_event_payload( + hass, calls, fake_schedule, set_time_zone, create_data, fire_time, payload_data +): + """Test the fields in the calendar event payload are set.""" + fake_schedule.create_event(**create_data) await create_automation(hass, EVENT_START) assert len(calls()) == 0 - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00") - ) + await fake_schedule.fire_until(fire_time) assert calls() == [ { "platform": "calendar", "event": EVENT_START, - "calendar_event": event_data, + "calendar_event": payload_data, } ] diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index 1217997a9968f8..3417399d729ea0 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -20,7 +20,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test camera registered attributes to be excluded.""" await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/clicksend_tts/__init__.py b/tests/components/clicksend_tts/__init__.py new file mode 100644 index 00000000000000..c822773ef709d7 --- /dev/null +++ b/tests/components/clicksend_tts/__init__.py @@ -0,0 +1 @@ +"""Tests for the ClickSend TTS component.""" diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py new file mode 100644 index 00000000000000..9bebb3cfbcad27 --- /dev/null +++ b/tests/components/clicksend_tts/test_notify.py @@ -0,0 +1,122 @@ +"""The test for the Facebook notify module.""" +import base64 +from http import HTTPStatus +import logging +from unittest.mock import patch + +import pytest +import requests_mock + +from homeassistant.components import notify +import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +# Infos from https://developers.clicksend.com/docs/rest/v3/#testing +TEST_USERNAME = "nocredit" +TEST_API_KEY = "D83DED51-9E35-4D42-9BB9-0E34B7CA85AE" +TEST_VOICE_NUMBER = "+61411111111" + +TEST_VOICE = "male" +TEST_LANGUAGE = "fr-fr" +TEST_MESSAGE = "Just a test message!" + + +CONFIG = { + notify.DOMAIN: { + "platform": "clicksend_tts", + cs_tts.CONF_USERNAME: TEST_USERNAME, + cs_tts.CONF_API_KEY: TEST_API_KEY, + cs_tts.CONF_RECIPIENT: TEST_VOICE_NUMBER, + cs_tts.CONF_LANGUAGE: TEST_LANGUAGE, + cs_tts.CONF_VOICE: TEST_VOICE, + } +} + + +@pytest.fixture +def mock_clicksend_tts_notify(): + """Mock Clicksend TTS notify service.""" + with patch( + "homeassistant.components.clicksend_tts.notify.get_service", autospec=True + ) as ns: + yield ns + + +async def setup_notify(hass): + """Test setup.""" + with assert_setup_component(1, notify.DOMAIN) as config: + assert await async_setup_component(hass, notify.DOMAIN, CONFIG) + assert config[notify.DOMAIN] + await hass.async_block_till_done() + + +async def test_no_notify_service(hass, mock_clicksend_tts_notify, caplog): + """Test missing platform notify service instance.""" + caplog.set_level(logging.ERROR) + mock_clicksend_tts_notify.return_value = None + await setup_notify(hass) + await hass.async_block_till_done() + assert mock_clicksend_tts_notify.called + assert "Failed to initialize notification service clicksend_tts" in caplog.text + + +async def test_send_simple_message(hass): + """Test sending a simple message with success.""" + + with requests_mock.Mocker() as mock: + # Mocking authentication endpoint + mock.get( + f"{cs_tts.BASE_API_URL}/account", + status_code=HTTPStatus.OK, + ) + + # Mocking TTS endpoint + mock.post( + f"{cs_tts.BASE_API_URL}/voice/send", + status_code=HTTPStatus.OK, + ) + + # Setting up integration + await setup_notify(hass) + + # Sending message + data = { + notify.ATTR_MESSAGE: TEST_MESSAGE, + } + await hass.services.async_call( + notify.DOMAIN, cs_tts.DEFAULT_NAME, data, blocking=True + ) + + # Checking if everything went well + assert mock.called + assert mock.call_count == 2 + + expected_body = { + "messages": [ + { + "source": "hass.notify", + "to": TEST_VOICE_NUMBER, + "body": TEST_MESSAGE, + "lang": TEST_LANGUAGE, + "voice": TEST_VOICE, + } + ] + } + assert mock.last_request.json() == expected_body + + expected_content_type = "application/json" + assert ( + "Content-Type" in mock.last_request.headers.keys() + and mock.last_request.headers["Content-Type"] == expected_content_type + ) + + encoded_auth = base64.b64encode( + f"{TEST_USERNAME}:{TEST_API_KEY}".encode() + ).decode() + expected_auth = f"Basic {encoded_auth}" + assert ( + "Authorization" in mock.last_request.headers + and mock.last_request.headers["Authorization"] == expected_auth + ) diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index bf254d2c02fb66..be3a0f22856e4d 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -26,7 +26,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test climate registered attributes to be excluded.""" await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index b4927ca1b66a9b..80d394c38ef187 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -10,7 +10,6 @@ CONF_CURRENCIES, CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_RATES, - CONF_YAML_API_TOKEN, DOMAIN, ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN @@ -23,8 +22,6 @@ ) from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE -from tests.common import MockConfigEntry - async def test_form(hass): """Test we get the form.""" @@ -44,8 +41,6 @@ async def test_form(hass): "coinbase.wallet.client.Client.get_exchange_rates", return_value=mock_get_exchange_rates(), ), patch( - "homeassistant.components.coinbase.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.coinbase.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -61,7 +56,6 @@ async def test_form(hass): assert result2["type"] == "create_entry" assert result2["title"] == "Test User" assert result2["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -303,62 +297,3 @@ async def test_option_catch_all_exception(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} - - -async def test_yaml_import(hass): - """Test YAML import works.""" - conf = { - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - CONF_CURRENCIES: ["BTC", "USD"], - CONF_EXCHANGE_RATES: ["ATOM", "BTC"], - } - with patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), - ), patch( - "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts - ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), - ), patch( - "homeassistant.components.coinbase.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.coinbase.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == "create_entry" - assert result["title"] == "Test User" - assert result["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} - assert result["options"] == { - CONF_CURRENCIES: ["BTC", "USD"], - CONF_EXCHANGE_RATES: ["ATOM", "BTC"], - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_yaml_existing(hass): - """Test YAML ignored when already processed.""" - MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: "123456", - CONF_API_TOKEN: "AbCDeF", - }, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index efb5ba85f731f1..4f8538a54460e5 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -6,13 +6,10 @@ API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, - CONF_YAML_API_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry -from homeassistant.setup import async_setup_component from .common import ( init_mock_coinbase, @@ -28,37 +25,6 @@ ) -async def test_setup(hass): - """Test setting up from configuration.yaml.""" - conf = { - DOMAIN: { - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], - } - } - with patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), - ), patch( - "coinbase.wallet.client.Client.get_accounts", - new=mocked_get_accounts, - ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), - ): - assert await async_setup_component(hass, DOMAIN, conf) - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].title == "Test User" - assert entries[0].source == config_entries.SOURCE_IMPORT - assert entries[0].options == { - CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], - } - - async def test_unload_entry(hass): """Test successful unload of entry.""" with patch( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index d36f7282bdbd0c..29a2395a92619a 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -51,7 +51,15 @@ async def test_get_entries(hass, client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "hub"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp5", partial_manifest={"integration_type": "service"}) + ) @HANDLERS.register("comp1") class Comp1ConfigFlow: @@ -91,6 +99,16 @@ def async_supports_options_flow(cls, config_entry): source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, ).add_to_hass(hass) + MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp5", + title="Test 5", + source="bla5", + ).add_to_hass(hass) resp = await client.get("/api/config/config_entries/entry") assert resp.status == HTTPStatus.OK @@ -137,6 +155,32 @@ def async_supports_options_flow(cls, config_entry): "disabled_by": core_ce.ConfigEntryDisabler.USER, "reason": None, }, + { + "domain": "comp4", + "title": "Test 4", + "source": "bla4", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": None, + }, + { + "domain": "comp5", + "title": "Test 5", + "source": "bla5", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": None, + }, ] resp = await client.get("/api/config/config_entries/entry?domain=comp3") @@ -150,19 +194,24 @@ def async_supports_options_flow(cls, config_entry): data = await resp.json() assert len(data) == 0 - resp = await client.get( - "/api/config/config_entries/entry?domain=comp3&type=integration" - ) + resp = await client.get("/api/config/config_entries/entry?type=hub") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 2 + assert data[0]["domain"] == "comp1" + assert data[1]["domain"] == "comp3" + + resp = await client.get("/api/config/config_entries/entry?type=device") assert resp.status == HTTPStatus.OK data = await resp.json() assert len(data) == 1 + assert data[0]["domain"] == "comp4" - resp = await client.get("/api/config/config_entries/entry?type=integration") + resp = await client.get("/api/config/config_entries/entry?type=service") assert resp.status == HTTPStatus.OK data = await resp.json() - assert len(data) == 2 - assert data[0]["domain"] == "comp1" - assert data[1]["domain"] == "comp3" + assert len(data) == 1 + assert data[0]["domain"] == "comp5" async def test_remove_entry(hass, client): @@ -1123,7 +1172,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "hub"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp5", partial_manifest={"integration_type": "service"}) + ) + entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1143,6 +1201,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, ).add_to_hass(hass) + MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp5", + title="Test 5", + source="bla5", + ).add_to_hass(hass) ws_client = await hass_ws_client(hass) @@ -1197,6 +1265,34 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "supports_unload": False, "title": "Test 3", }, + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, ] await ws_client.send_json( @@ -1204,7 +1300,7 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "id": 6, "type": "config_entries/get", "domain": "comp1", - "type_filter": "integration", + "type_filter": "hub", } ) response = await ws_client.receive_json() @@ -1225,22 +1321,102 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "title": "Test 1", } ] - # Verify we skip broken integrations + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/get", + "type_filter": ["service", "device"], + } + ) + response = await ws_client.receive_json() + assert response["id"] == 7 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, + ] + + await ws_client.send_json( + { + "id": 8, + "type": "config_entries/get", + "type_filter": "hub", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 8 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + # Verify we skip broken integrations with patch( "homeassistant.components.config.config_entries.async_get_integration", side_effect=IntegrationNotFound("any"), ): await ws_client.send_json( { - "id": 7, + "id": 9, "type": "config_entries/get", - "type_filter": "integration", + "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 7 + assert response["id"] == 9 assert response["result"] == [ { "disabled_by": None, @@ -1284,8 +1460,53 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "supports_unload": False, "title": "Test 3", }, + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, ] + # Verify we don't send config entries when only helpers are requested + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=IntegrationNotFound("any"), + ): + await ws_client.send_json( + { + "id": 10, + "type": "config_entries/get", + "type_filter": ["helper"], + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 10 + assert response["result"] == [] + # Verify we raise if something really goes wrong with patch( @@ -1294,14 +1515,14 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): ): await ws_client.send_json( { - "id": 8, + "id": 11, "type": "config_entries/get", - "type_filter": "integration", + "type_filter": ["device", "hub", "service"], } ) response = await ws_client.receive_json() - assert response["id"] == 8 + assert response["id"] == 11 assert response["success"] is False @@ -1312,7 +1533,9 @@ async def test_subscribe_entries_ws(hass, hass_ws_client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "device"}) + ) entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1476,7 +1699,12 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "service"}) + ) entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1491,12 +1719,19 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler reason="Unsupported API", ) entry2.add_to_hass(hass) - MockConfigEntry( + entry3 = MockConfigEntry( domain="comp3", title="Test 3", source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, - ).add_to_hass(hass) + ) + entry3.add_to_hass(hass) + entry4 = MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ) + entry4.add_to_hass(hass) ws_client = await hass_ws_client(hass) @@ -1504,7 +1739,7 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler { "id": 5, "type": "config_entries/subscribe", - "type_filter": "integration", + "type_filter": ["hub", "device"], } ) response = await ws_client.receive_json() @@ -1551,6 +1786,8 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler }, ] assert hass.config_entries.async_update_entry(entry, title="changed") + assert hass.config_entries.async_update_entry(entry3, title="changed too") + assert hass.config_entries.async_update_entry(entry4, title="changed but ignored") response = await ws_client.receive_json() assert response["id"] == 5 assert response["event"] == [ @@ -1572,6 +1809,27 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler "type": "updated", } ] + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed too", + }, + "type": "updated", + } + ] await hass.config_entries.async_remove(entry.entry_id) await hass.config_entries.async_remove(entry2.entry_id) response = await ws_client.receive_json() diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 2f4cd980d8e1f1..ee46838b01c6cb 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -345,7 +345,7 @@ async def test_update_entity(hass, client): "platform": "test_platform", "unique_id": "1234", }, - "reload_delay": 30, + "require_restart": True, } # UPDATE ENTITY OPTION diff --git a/tests/components/darksky/test_sensor.py b/tests/components/darksky/test_sensor.py index 02bc392cd6803d..3d1f8fb26bc9d8 100644 --- a/tests/components/darksky/test_sensor.py +++ b/tests/components/darksky/test_sensor.py @@ -1,17 +1,14 @@ """The tests for the Dark Sky platform.""" from datetime import timedelta import re -import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch import forecastio -from requests.exceptions import HTTPError -import requests_mock +from requests.exceptions import ConnectionError as ConnectError -from homeassistant.components.darksky import sensor as darksky -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, load_fixture +from tests.common import load_fixture VALID_CONFIG_MINIMAL = { "sensor": { @@ -69,140 +66,100 @@ } } -VALID_CONFIG_ALERTS = { - "sensor": { - "platform": "darksky", - "api_key": "foo", - "forecast": [1, 2], - "hourly_forecast": [1, 2], - "monitored_conditions": ["summary", "icon", "temperature_high", "alerts"], - "scan_interval": timedelta(seconds=120), - } -} +async def test_setup_with_config(hass, requests_mock): + """Test the platform setup with configuration.""" + with patch("homeassistant.components.darksky.sensor.forecastio.load_forecast"): + assert await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() -def load_forecastMock(key, lat, lon, units, lang): # pylint: disable=invalid-name - """Mock darksky forecast loading.""" - return "" + state = hass.states.get("sensor.dark_sky_summary") + assert state is not None -class TestDarkSkySetup(unittest.TestCase): - """Test the Dark Sky platform.""" +async def test_setup_with_invalid_config(hass): + """Test the platform setup with invalid configuration.""" + assert await async_setup_component(hass, "sensor", INVALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - def add_entities(self, new_entities, update_before_add=False): - """Mock add entities.""" - if update_before_add: - for entity in new_entities: - entity.update() + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - for entity in new_entities: - self.entities.append(entity) - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - self.key = "foo" - self.lat = self.hass.config.latitude = 37.8267 - self.lon = self.hass.config.longitude = -122.423 - self.entities = [] - self.addCleanup(self.tear_down_cleanup) +async def test_setup_with_language_config(hass): + """Test the platform setup with language configuration.""" + with patch("homeassistant.components.darksky.sensor.forecastio.load_forecast"): + assert await async_setup_component(hass, "sensor", VALID_CONFIG_LANG_DE) + await hass.async_block_till_done() - def tear_down_cleanup(self): - """Stop everything that was started.""" - self.hass.stop() + state = hass.states.get("sensor.dark_sky_summary") + assert state is not None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, - ) - def test_setup_with_config(self): - """Test the platform setup with configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_MINIMAL) - self.hass.block_till_done() - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is not None +async def test_setup_with_invalid_language_config(hass): + """Test the platform setup with language configuration.""" + assert await async_setup_component(hass, "sensor", INVALID_CONFIG_LANG) + await hass.async_block_till_done() - def test_setup_with_invalid_config(self): - """Test the platform setup with invalid configuration.""" - setup_component(self.hass, "sensor", INVALID_CONFIG_MINIMAL) - self.hass.block_till_done() + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, +async def test_setup_bad_api_key(hass, requests_mock): + """Test for handling a bad API key.""" + # The Dark Sky API wrapper that we use raises an HTTP error + # when you try to use a bad (or no) API key. + url = "https://api.darksky.net/forecast/{}/{},{}?units=auto".format( + "foo", str(hass.config.latitude), str(hass.config.longitude) ) - def test_setup_with_language_config(self): - """Test the platform setup with language configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_LANG_DE) - self.hass.block_till_done() + msg = f"400 Client Error: Bad Request for url: {url}" + requests_mock.get(url, text=msg, status_code=400) - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is not None + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "darksky", "api_key": "foo"}} + ) + await hass.async_block_till_done() - def test_setup_with_invalid_language_config(self): - """Test the platform setup with language configuration.""" - setup_component(self.hass, "sensor", INVALID_CONFIG_LANG) - self.hass.block_till_done() + assert hass.states.get("sensor.dark_sky_summary") is None - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is None - @patch("forecastio.api.get_forecast") - def test_setup_bad_api_key(self, mock_get_forecast): - """Test for handling a bad API key.""" - # The Dark Sky API wrapper that we use raises an HTTP error - # when you try to use a bad (or no) API key. - url = "https://api.darksky.net/forecast/{}/{},{}?units=auto".format( - self.key, str(self.lat), str(self.lon) - ) - msg = f"400 Client Error: Bad Request for url: {url}" - mock_get_forecast.side_effect = HTTPError(msg) +async def test_connection_error(hass): + """Test setting up with a connection error.""" + with patch( + "homeassistant.components.darksky.sensor.forecastio.load_forecast", + side_effect=ConnectError(), + ): + await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - response = darksky.setup_platform( - self.hass, VALID_CONFIG_MINIMAL["sensor"], MagicMock() - ) - assert not response + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, - ) - def test_setup_with_alerts_config(self): - """Test the platform setup with alert configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_ALERTS) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.dark_sky_alerts") - assert state.state == "0" - - @requests_mock.Mocker() - @patch("forecastio.api.get_forecast", wraps=forecastio.api.get_forecast) - def test_setup(self, mock_req, mock_get_forecast): - """Test for successfully setting up the forecast.io platform.""" + +async def test_setup(hass, requests_mock): + """Test for successfully setting up the forecast.io platform.""" + with patch( + "forecastio.api.get_forecast", wraps=forecastio.api.get_forecast + ) as mock_get_forecast: uri = ( r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" r"(-?\d+\.?\d*),(-?\d+\.?\d*)" ) - mock_req.get(re.compile(uri), text=load_fixture("darksky.json")) + requests_mock.get(re.compile(uri), text=load_fixture("darksky.json")) - assert setup_component(self.hass, "sensor", VALID_CONFIG_MINIMAL) - self.hass.block_till_done() + assert await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - assert len(self.hass.states.entity_ids()) == 13 + assert len(hass.states.async_entity_ids()) == 13 - state = self.hass.states.get("sensor.dark_sky_summary") + state = hass.states.get("sensor.dark_sky_summary") assert state is not None assert state.state == "Clear" assert state.attributes.get("friendly_name") == "Dark Sky Summary" - state = self.hass.states.get("sensor.dark_sky_alerts") + state = hass.states.get("sensor.dark_sky_alerts") assert state.state == "2" - state = self.hass.states.get("sensor.dark_sky_daytime_high_temperature_1d") + state = hass.states.get("sensor.dark_sky_daytime_high_temperature_1d") assert state is not None assert state.attributes.get("device_class") == "temperature" diff --git a/tests/components/darksky/test_weather.py b/tests/components/darksky/test_weather.py index 9a1b3912b87cff..4a982577d481cf 100644 --- a/tests/components/darksky/test_weather.py +++ b/tests/components/darksky/test_weather.py @@ -1,67 +1,50 @@ """The tests for the Dark Sky weather component.""" import re -import unittest from unittest.mock import patch import forecastio -from requests.exceptions import ConnectionError -import requests_mock +from requests.exceptions import ConnectionError as ConnectError from homeassistant.components import weather -from homeassistant.setup import setup_component -from homeassistant.util.unit_system import METRIC_SYSTEM - -from tests.common import get_test_home_assistant, load_fixture - - -class TestDarkSky(unittest.TestCase): - """Test the Dark Sky weather component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = METRIC_SYSTEM - self.lat = self.hass.config.latitude = 37.8267 - self.lon = self.hass.config.longitude = -122.423 - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Stop down everything that was started.""" - self.hass.stop() - - @requests_mock.Mocker() - @patch("forecastio.api.get_forecast", wraps=forecastio.api.get_forecast) - def test_setup(self, mock_req, mock_get_forecast): - """Test for successfully setting up the forecast.io platform.""" - uri = ( - r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" - r"(-?\d+\.?\d*),(-?\d+\.?\d*)" +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture + + +async def test_setup(hass, requests_mock): + """Test for successfully setting up the forecast.io platform.""" + with patch( + "forecastio.api.get_forecast", wraps=forecastio.api.get_forecast + ) as mock_get_forecast: + requests_mock.get( + re.compile( + r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" + r"(-?\d+\.?\d*),(-?\d+\.?\d*)" + ), + text=load_fixture("darksky.json"), ) - mock_req.get(re.compile(uri), text=load_fixture("darksky.json")) - assert setup_component( - self.hass, + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"name": "test", "platform": "darksky", "api_key": "foo"}}, ) - self.hass.block_till_done() + await hass.async_block_till_done() - assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - - state = self.hass.states.get("weather.test") + state = hass.states.get("weather.test") assert state.state == "sunny" - @patch("forecastio.load_forecast", side_effect=ConnectionError()) - def test_failed_setup(self, mock_load_forecast): - """Test to ensure that a network error does not break component state.""" - assert setup_component( - self.hass, +async def test_failed_setup(hass): + """Test to ensure that a network error does not break component state.""" + with patch("forecastio.load_forecast", side_effect=ConnectError()): + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"name": "test", "platform": "darksky", "api_key": "foo"}}, ) - self.hass.block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get("weather.test") + state = hass.states.get("weather.test") assert state.state == "unavailable" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 2f21081a5ae15c..7a4c73923ec995 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -545,7 +545,9 @@ async def test_flow_hassio_discovery(hass): CONF_PORT: 80, CONF_SERIAL: BRIDGEID, CONF_API_KEY: API_KEY, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) @@ -593,7 +595,9 @@ async def test_hassio_discovery_update_configuration(hass, aioclient_mock): CONF_PORT: 8080, CONF_API_KEY: "updated", CONF_SERIAL: BRIDGEID, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) @@ -619,7 +623,9 @@ async def test_hassio_discovery_dont_update_configuration(hass, aioclient_mock): CONF_PORT: 80, CONF_API_KEY: API_KEY, CONF_SERIAL: BRIDGEID, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index e0c469a1ba2f97..63dac8dde377a4 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -44,7 +45,8 @@ async def test_no_number_entities(hass, aioclient_mock): "entity_count": 3, "device_count": 3, "entity_id": "number.presence_sensor_delay", - "unique_id": "00:00:00:00:00:00:00:00-delay", + "unique_id": "00:00:00:00:00:00:00:00-00-delay", + "old_unique_id": "00:00:00:00:00:00:00:00-delay", "state": "0", "entity_category": EntityCategory.CONFIG, "attributes": { @@ -62,7 +64,43 @@ async def test_no_number_entities(hass, aioclient_mock): "unsupported_service_response": {"delay": 0}, "out_of_range_service_value": 66666, }, - ) + ), + ( # Presence sensor - duration configuration + { + "name": "Presence sensor", + "type": "ZHAPresence", + "state": {"dark": False, "presence": False}, + "config": { + "duration": 0, + "on": True, + "reachable": True, + "temperature": 10, + }, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + { + "entity_count": 3, + "device_count": 3, + "entity_id": "number.presence_sensor_duration", + "unique_id": "00:00:00:00:00:00:00:00-00-duration", + "state": "0", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "min": 0, + "max": 65535, + "step": 1, + "mode": "auto", + "friendly_name": "Presence sensor Duration", + }, + "websocket_event": {"config": {"duration": 10}}, + "next_state": "10", + "supported_service_value": 111, + "supported_service_response": {"duration": 111}, + "unsupported_service_value": 0.1, + "unsupported_service_response": {"duration": 0}, + "out_of_range_service_value": 66666, + }, + ), ] @@ -74,6 +112,15 @@ async def test_number_entities( ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Create entity entry to migrate to new unique ID + if "old_unique_id" in expected: + ent_reg.async_get_or_create( + NUMBER_DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace("number.", ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"0": sensor_data}}): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -105,8 +152,7 @@ async def test_number_entities( "e": "changed", "r": "sensors", "id": "0", - "config": {"delay": 10}, - } + } | expected["websocket_event"] await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get(expected["entity_id"]).state == expected["next_state"] diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 1078d888c0ed80..ac8100caa3d848 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -104,7 +104,6 @@ async def test_no_sensors(hass, aioclient_mock): "state_class": SensorStateClass.MEASUREMENT, "attributes": { "state_class": "measurement", - "unit_of_measurement": "ppb", "device_class": "aqi", "friendly_name": "BOSCH Air quality sensor PPB", }, @@ -521,9 +520,7 @@ async def test_no_sensors(hass, aioclient_mock): "state": "2020-11-19T08:07:08+00:00", "entity_category": None, "device_class": SensorDeviceClass.TIMESTAMP, - "state_class": SensorStateClass.TOTAL_INCREASING, "attributes": { - "state_class": "total_increasing", "device_class": "timestamp", "friendly_name": "eTRV Séjour", }, diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index f8f8c20dbb2417..186e019fcbb012 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -12,7 +12,9 @@ @pytest.fixture(autouse=True) def mock_ssdp(): """Mock ssdp.""" - with patch("homeassistant.components.ssdp.Scanner.async_scan"): + with patch("homeassistant.components.ssdp.Scanner.async_scan"), patch( + "homeassistant.components.ssdp.Server.async_start" + ), patch("homeassistant.components.ssdp.Server.async_stop"): yield diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index f7a89fe63c1e53..c1b9d4c436e862 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -17,25 +17,25 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done -@pytest.fixture(autouse=True) +@pytest.fixture def mock_history(hass): """Mock history component loaded.""" hass.config.components.add("history") @pytest.fixture(autouse=True) -def mock_device_tracker_update_config(hass): +def mock_device_tracker_update_config(): """Prevent device tracker from creating known devices file.""" with patch("homeassistant.components.device_tracker.legacy.update_config"): yield -async def test_setting_up_demo(hass): +async def test_setting_up_demo(mock_history, hass): """Test if we can set up the demo and dump it to JSON.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -52,7 +52,7 @@ async def test_setting_up_demo(hass): ) -async def test_demo_statistics(hass, recorder_mock): +async def test_demo_statistics(recorder_mock, mock_history, hass): """Test that the demo components makes some statistics available.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -82,9 +82,9 @@ async def test_demo_statistics(hass, recorder_mock): } in statistic_ids -async def test_demo_statistics_growth(hass, recorder_mock): +async def test_demo_statistics_growth(recorder_mock, mock_history, hass): """Test that the demo sum statistics adds to the previous state.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM now = dt_util.now() last_week = now - datetime.timedelta(days=7) @@ -120,7 +120,7 @@ async def test_demo_statistics_growth(hass, recorder_mock): assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) -async def test_issues_created(hass, hass_client, hass_ws_client): +async def test_issues_created(mock_history, hass, hass_client, hass_ws_client): """Test issues are created and can be fixed.""" assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index a6b4d999ddbf09..75103fca920a17 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -4,7 +4,7 @@ from homeassistant.components import water_heater from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.water_heater import common @@ -15,7 +15,7 @@ @pytest.fixture(autouse=True) async def setup_comp(hass): """Set up demo component.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM assert await async_setup_component( hass, water_heater.DOMAIN, {"water_heater": {"platform": "demo"}} ) diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py index bb861081517f47..f42abef20ec2c8 100644 --- a/tests/components/devolo_home_network/__init__.py +++ b/tests/components/devolo_home_network/__init__.py @@ -1,16 +1,9 @@ """Tests for the devolo Home Network integration.""" - -import dataclasses -from typing import Any - -from devolo_plc_api.device_api.deviceapi import DeviceApi -from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi - from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DISCOVERY_INFO, IP +from .const import IP from tests.common import MockConfigEntry @@ -19,17 +12,9 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } entry = MockConfigEntry(domain=DOMAIN, data=config) entry.add_to_hass(hass) return entry - - -async def async_connect(self, session_instance: Any = None): - """Give a mocked device the needed properties.""" - self.plcnet = PlcNetApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) - self.device = DeviceApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) - self.mac = DISCOVERY_INFO.properties["PlcMacAddress"] - self.product = DISCOVERY_INFO.properties["Product"] - self.serial_number = DISCOVERY_INFO.properties["SN"] diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index 1d8d2a6da19532..98a79faae54c36 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -1,29 +1,22 @@ """Fixtures for tests.""" - -from unittest.mock import AsyncMock, patch +from itertools import cycle +from unittest.mock import patch import pytest -from . import async_connect -from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NEIGHBOR_ACCESS_POINTS, PLCNET +from .const import DISCOVERY_INFO, IP +from .mock import MockDevice @pytest.fixture() def mock_device(): """Mock connecting to a devolo home network device.""" - with patch("devolo_plc_api.device.Device.async_connect", async_connect), patch( - "devolo_plc_api.device.Device.async_disconnect" - ), patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=CONNECTED_STATIONS), - ), patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", - new=AsyncMock(return_value=NEIGHBOR_ACCESS_POINTS), - ), patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - new=AsyncMock(return_value=PLCNET), + device = MockDevice(ip=IP) + with patch( + "homeassistant.components.devolo_home_network.Device", + side_effect=cycle([device]), ): - yield + yield device @pytest.fixture(name="info") diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py new file mode 100644 index 00000000000000..660cc19f78c4dd --- /dev/null +++ b/tests/components/devolo_home_network/mock.py @@ -0,0 +1,57 @@ +"""Mock of a devolo Home Network device.""" +from __future__ import annotations + +import dataclasses +from typing import Any +from unittest.mock import AsyncMock + +from devolo_plc_api.device import Device +from devolo_plc_api.device_api.deviceapi import DeviceApi +from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi +import httpx +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf + +from .const import ( + CONNECTED_STATIONS, + DISCOVERY_INFO, + IP, + NEIGHBOR_ACCESS_POINTS, + PLCNET, +) + + +class MockDevice(Device): + """Mock of a devolo Home Network device.""" + + def __init__( + self, + ip: str, + plcnetapi: dict[str, Any] | None = None, + deviceapi: dict[str, Any] | None = None, + zeroconf_instance: AsyncZeroconf | Zeroconf | None = None, + ) -> None: + """Bring mock in a well defined state.""" + super().__init__(ip, plcnetapi, deviceapi, zeroconf_instance) + self.reset() + + async def async_connect( + self, session_instance: httpx.AsyncClient | None = None + ) -> None: + """Give a mocked device the needed properties.""" + self.mac = DISCOVERY_INFO.properties["PlcMacAddress"] + self.product = DISCOVERY_INFO.properties["Product"] + self.serial_number = DISCOVERY_INFO.properties["SN"] + + def reset(self): + """Reset mock to starting point.""" + self.async_disconnect = AsyncMock() + self.device = DeviceApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) + self.device.async_get_wifi_connected_station = AsyncMock( + return_value=CONNECTED_STATIONS + ) + self.device.async_get_wifi_neighbor_access_points = AsyncMock( + return_value=NEIGHBOR_ACCESS_POINTS + ) + self.plcnet = PlcNetApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) + self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 8f9936be5bb994..d18dbca1f5f3eb 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -1,5 +1,5 @@ """Tests for the devolo Home Network sensors.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest @@ -22,6 +22,7 @@ from . import configure_integration from .const import PLCNET_ATTACHED +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -39,8 +40,8 @@ async def test_binary_sensor_setup(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_attached_to_router(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_attached_to_router(hass: HomeAssistant, mock_device: MockDevice): """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -59,27 +60,25 @@ async def test_update_attached_to_router(hass: HomeAssistant): assert er.async_get(state_key).entity_category == EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - new=AsyncMock(return_value=PLCNET_ATTACHED), - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON + mock_device.plcnet.async_get_network_overview = AsyncMock( + return_value=PLCNET_ATTACHED + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index f9d589eb638a7e..0d35630407eb6f 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -7,18 +7,20 @@ from devolo_plc_api.exceptions.device import DeviceNotFound import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.devolo_home_network import config_flow from homeassistant.components.devolo_home_network.const import ( DOMAIN, SERIAL_NUMBER, TITLE, ) -from homeassistant.const import CONF_BASE, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.const import CONF_BASE, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import configure_integration from .const import DISCOVERY_INFO, DISCOVERY_INFO_WRONG_DEVICE, IP +from .mock import MockDevice async def test_form(hass: HomeAssistant, info: dict[str, Any]): @@ -46,6 +48,7 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]): assert result2["title"] == info["title"] assert result2["data"] == { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } assert len(mock_setup_entry.mock_calls) == 1 @@ -111,6 +114,7 @@ async def test_zeroconf(hass: HomeAssistant): assert result2["title"] == "test" assert result2["data"] == { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } @@ -167,10 +171,53 @@ async def test_abort_if_configued(hass: HomeAssistant): assert result3["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_reauth(hass: HomeAssistant): + """Test that the reauth confirmation form is served.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "title_placeholders": { + CONF_NAME: DISCOVERY_INFO.hostname.split(".")[0], + }, + }, + data=entry.data, + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + with patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-password-new"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + @pytest.mark.usefixtures("mock_device") @pytest.mark.usefixtures("mock_zeroconf") async def test_validate_input(hass: HomeAssistant): """Test input validation.""" - info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) - assert SERIAL_NUMBER in info - assert TITLE in info + with patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ): + info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) + assert SERIAL_NUMBER in info + assert TITLE in info diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 233a480b5e3ca2..2f8fea3e749e96 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -1,8 +1,7 @@ """Tests for the devolo Home Network device tracker.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable -import pytest from homeassistant.components.device_tracker import DOMAIN as PLATFORM from homeassistant.components.devolo_home_network.const import ( @@ -23,6 +22,7 @@ from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -30,8 +30,7 @@ SERIAL = DISCOVERY_INFO.properties["SN"] -@pytest.mark.usefixtures("mock_device") -async def test_device_tracker(hass: HomeAssistant): +async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice): """Test device tracker states.""" state_key = f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION['mac_address'].lower().replace(':', '_')}" entry = configure_integration(hass) @@ -57,34 +56,31 @@ async def test_device_tracker(hass: HomeAssistant): ) # Emulate state change - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=NO_CONNECTED_STATIONS), - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + return_value=NO_CONNECTED_STATIONS + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_NOT_HOME + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_NOT_HOME # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("mock_device") -async def test_restoring_clients(hass: HomeAssistant): +async def test_restoring_clients(hass: HomeAssistant, mock_device: MockDevice): """Test restoring existing device_tracker entities.""" state_key = f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION['mac_address'].lower().replace(':', '_')}" entry = configure_integration(hass) @@ -96,12 +92,13 @@ async def test_restoring_clients(hass: HomeAssistant): config_entry=entry, ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=NO_CONNECTED_STATIONS), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_NOT_HOME + mock_device.device.async_get_wifi_connected_station = AsyncMock( + return_value=NO_CONNECTED_STATIONS + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_NOT_HOME diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 1d15f337c1737f..524590d7ead182 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -4,11 +4,16 @@ from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import configure_integration +from .const import IP +from .mock import MockDevice + +from tests.common import MockConfigEntry @pytest.mark.usefixtures("mock_device") @@ -23,6 +28,22 @@ async def test_setup_entry(hass: HomeAssistant): assert entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("mock_device") +async def test_setup_without_password(hass: HomeAssistant): + """Test setup entry without a device password set like used before HA Core 2022.06.""" + config = { + CONF_IP_ADDRESS: IP, + } + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ), patch("homeassistant.core.EventBus.async_listen_once"): + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + async def test_setup_device_not_found(hass: HomeAssistant): """Test setup entry.""" entry = configure_integration(hass) @@ -44,15 +65,11 @@ async def test_unload_entry(hass: HomeAssistant): assert entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("mock_device") -async def test_hass_stop(hass: HomeAssistant): +async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice): """Test homeassistant stop event.""" entry = configure_integration(hass) - with patch( - "homeassistant.components.devolo_home_network.Device.async_disconnect" - ) as async_disconnect: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - async_disconnect.assert_called_once() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_device.async_disconnect.assert_called_once() diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 33499f512faaf2..3002bd7c5b88d1 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -1,5 +1,5 @@ """Tests for the devolo Home Network sensors.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest @@ -16,6 +16,7 @@ from homeassistant.util import dt from . import configure_integration +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -35,8 +36,9 @@ async def test_sensor_setup(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("mock_device") -async def test_update_connected_wifi_clients(hass: HomeAssistant): +async def test_update_connected_wifi_clients( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a connected_wifi_clients sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -53,18 +55,18 @@ async def test_update_connected_wifi_clients(hass: HomeAssistant): assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -75,8 +77,10 @@ async def test_update_connected_wifi_clients(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_neighboring_wifi_networks(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_neighboring_wifi_networks( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a neighboring_wifi_networks sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -95,18 +99,18 @@ async def test_update_neighboring_wifi_networks(hass: HomeAssistant): assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -117,8 +121,10 @@ async def test_update_neighboring_wifi_networks(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_connected_plc_devices(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_connected_plc_devices( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a connected_plc_devices sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -136,18 +142,18 @@ async def test_update_connected_plc_devices(hass: HomeAssistant): assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 84aec044caf9cf..521f770a8fab27 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -116,13 +116,20 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: @pytest.fixture(autouse=True) def ssdp_scanner_mock() -> Iterable[Mock]: - """Mock the SSDP module.""" + """Mock the SSDP Scanner.""" with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: reg_callback = mock_scanner.return_value.async_register_callback reg_callback.return_value = Mock(return_value=None) yield mock_scanner.return_value +@pytest.fixture(autouse=True) +def ssdp_server_mock() -> Iterable[Mock]: + """Mock the SSDP Server.""" + with patch("homeassistant.components.ssdp.Server", autospec=True): + yield + + @pytest.fixture(autouse=True) def async_get_local_ip_mock() -> Iterable[Mock]: """Mock the async_get_local_ip utility function to prevent network access.""" diff --git a/tests/components/dlna_dms/conftest.py b/tests/components/dlna_dms/conftest.py index 4dcd135ea8663e..5b785fb4ba5028 100644 --- a/tests/components/dlna_dms/conftest.py +++ b/tests/components/dlna_dms/conftest.py @@ -129,13 +129,20 @@ def dms_device_mock(upnp_factory_mock: Mock) -> Iterable[Mock]: @pytest.fixture(autouse=True) def ssdp_scanner_mock() -> Iterable[Mock]: - """Mock the SSDP module.""" + """Mock the SSDP Scanner.""" with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: reg_callback = mock_scanner.return_value.async_register_callback reg_callback.return_value = Mock(return_value=None) yield mock_scanner.return_value +@pytest.fixture(autouse=True) +def ssdp_server_mock() -> Iterable[Mock]: + """Mock the SSDP Server.""" + with patch("homeassistant.components.ssdp.Server", autospec=True): + yield + + @pytest.fixture async def device_source_mock( hass: HomeAssistant, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 364fdeb365b02e..0108dd1de7693c 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -16,16 +16,16 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, STATE_UNKNOWN, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, + VOLUME_GALLONS, + UnitOfEnergy, ) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -49,7 +49,7 @@ def get_statistics_for_entity(statistics_results, entity_id): return None -async def test_cost_sensor_no_states(hass, hass_storage, setup_integration) -> None: +async def test_cost_sensor_no_states(setup_integration, hass, hass_storage) -> None: """Test sensors are created.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -75,7 +75,7 @@ async def test_cost_sensor_no_states(hass, hass_storage, setup_integration) -> N # TODO: No states, should the cost entity refuse to setup? -async def test_cost_sensor_attributes(hass, hass_storage, setup_integration) -> None: +async def test_cost_sensor_attributes(setup_integration, hass, hass_storage) -> None: """Test sensor attributes.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -124,10 +124,10 @@ async def test_cost_sensor_attributes(hass, hass_storage, setup_integration) -> ], ) async def test_cost_sensor_price_entity_total_increasing( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -142,7 +142,7 @@ def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } @@ -327,10 +327,10 @@ def _compile_statistics(_): ) @pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -346,7 +346,7 @@ def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: energy_state_class, } @@ -533,10 +533,10 @@ def _compile_statistics(_): ) @pytest.mark.parametrize("energy_state_class", ["total"]) async def test_cost_sensor_price_entity_total_no_reset( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -552,7 +552,7 @@ def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: energy_state_class, } @@ -700,13 +700,14 @@ def _compile_statistics(_): @pytest.mark.parametrize( "energy_unit,factor", [ - (ENERGY_WATT_HOUR, 1000), - (ENERGY_KILO_WATT_HOUR, 1), - (ENERGY_MEGA_WATT_HOUR, 0.001), + (UnitOfEnergy.WATT_HOUR, 1000), + (UnitOfEnergy.KILO_WATT_HOUR, 1), + (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), + (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), ], ) async def test_cost_sensor_handle_energy_units( - hass, hass_storage, setup_integration, energy_unit, factor + setup_integration, hass, hass_storage, energy_unit, factor ) -> None: """Test energy cost price from sensor entity.""" energy_attributes = { @@ -765,17 +766,18 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( "price_unit,factor", [ - (f"EUR/{ENERGY_WATT_HOUR}", 0.001), - (f"EUR/{ENERGY_KILO_WATT_HOUR}", 1), - (f"EUR/{ENERGY_MEGA_WATT_HOUR}", 1000), + (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), + (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), + (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), + (f"EUR/{UnitOfEnergy.GIGA_JOULE}", 1000 / 3.6), ], ) async def test_cost_sensor_handle_price_units( - hass, hass_storage, setup_integration, price_unit, factor + setup_integration, hass, hass_storage, price_unit, factor ) -> None: """Test energy cost price from sensor entity.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } price_attributes = { @@ -832,9 +834,12 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" -@pytest.mark.parametrize("unit", (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS)) +@pytest.mark.parametrize( + "unit", + (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS), +) async def test_cost_sensor_handle_gas( - hass, hass_storage, setup_integration, unit + setup_integration, hass, hass_storage, unit ) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { @@ -884,11 +889,11 @@ async def test_cost_sensor_handle_gas( async def test_cost_sensor_handle_gas_kwh( - hass, hass_storage, setup_integration + setup_integration, hass, hass_storage ) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } energy_data = data.EnergyManager.default_preferences() @@ -933,13 +938,73 @@ async def test_cost_sensor_handle_gas_kwh( assert state.state == "50.0" +@pytest.mark.parametrize( + "unit_system,usage_unit,growth", + ( + # 1 cubic foot = 7.47 gl, 100 ft3 growth @ 0.5/ft3: + (US_CUSTOMARY_SYSTEM, VOLUME_CUBIC_FEET, 374.025974025974), + (US_CUSTOMARY_SYSTEM, VOLUME_GALLONS, 50.0), + (METRIC_SYSTEM, VOLUME_CUBIC_METERS, 50.0), + ), +) +async def test_cost_sensor_handle_water( + setup_integration, hass, hass_storage, unit_system, usage_unit, growth +) -> None: + """Test water cost price from sensor entity.""" + hass.config.units = unit_system + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: usage_unit, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "water", + "stat_energy_from": "sensor.water_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.water_consumption", + 100, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.water_consumption_cost") + assert state.state == "0.0" + + # water use bumped to 200 ft³/m³ + hass.states.async_set( + "sensor.water_consumption", + 200, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.water_consumption_cost") + assert float(state.state) == pytest.approx(growth) + + @pytest.mark.parametrize("state_class", [None]) async def test_cost_sensor_wrong_state_class( - hass, hass_storage, setup_integration, caplog, state_class + setup_integration, hass, hass_storage, caplog, state_class ) -> None: """Test energy sensor rejects sensor with wrong state_class.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, } energy_data = data.EnergyManager.default_preferences() @@ -996,11 +1061,11 @@ async def test_cost_sensor_wrong_state_class( @pytest.mark.parametrize("state_class", [SensorStateClass.MEASUREMENT]) async def test_cost_sensor_state_class_measurement_no_reset( - hass, hass_storage, setup_integration, caplog, state_class + setup_integration, hass, hass_storage, caplog, state_class ) -> None: """Test energy sensor rejects state_class measurement with no last_reset.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, } energy_data = data.EnergyManager.default_preferences() @@ -1051,7 +1116,7 @@ async def test_cost_sensor_state_class_measurement_no_reset( assert state.state == STATE_UNKNOWN -async def test_inherit_source_unique_id(hass, hass_storage, setup_integration): +async def test_inherit_source_unique_id(setup_integration, hass, hass_storage): """Test sensor inherits unique ID from source.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9e78e91f7f7a6b..f1e626c24d5c6f 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,11 +4,7 @@ import pytest from homeassistant.components.energy import async_get_manager, validate -from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, -) +from homeassistant.const import UnitOfEnergy from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -48,7 +44,7 @@ def _get_metadata(_hass, *, statistic_ids): @pytest.fixture(autouse=True) -async def mock_energy_manager(hass, recorder_mock): +async def mock_energy_manager(recorder_mock, hass): """Set up energy.""" assert await async_setup_component(hass, "energy", {"energy": {}}) manager = await async_get_manager(hass) @@ -67,12 +63,13 @@ async def test_validation_empty_config(hass): @pytest.mark.parametrize( "state_class, energy_unit, extra", [ - ("total_increasing", ENERGY_KILO_WATT_HOUR, {}), - ("total_increasing", ENERGY_MEGA_WATT_HOUR, {}), - ("total_increasing", ENERGY_WATT_HOUR, {}), - ("total", ENERGY_KILO_WATT_HOUR, {}), - ("total", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), - ("measurement", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), + ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), + ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), + ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), + ("total", UnitOfEnergy.KILO_WATT_HOUR, {}), + ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), + ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), + ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), ], ) async def test_validation( @@ -949,3 +946,162 @@ async def test_validation_grid_no_costs_tracking( "energy_sources": [[]], "device_consumption": [], } + + +async def test_validation_water( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): + """Test validating water with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.water_cost_1"] = False + mock_is_entity_recorded["sensor.water_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_1", + "stat_cost": "sensor.water_cost_1", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_2", + "stat_cost": "sensor.water_cost_2", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_3", + "stat_cost": "sensor.water_cost_2", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_4", + "entity_energy_price": "sensor.water_price_1", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_3", + "entity_energy_price": "sensor.water_price_2", + }, + ] + } + ) + await hass.async_block_till_done() + hass.states.async_set( + "sensor.water_consumption_1", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_2", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "ft³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_3", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_4", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_cost_2", + "10.10", + {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_price_1", + "10.10", + {"unit_of_measurement": "EUR/m³", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_price_2", + "10.10", + {"unit_of_measurement": "EUR/invalid", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_water", + "identifier": "sensor.water_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.water_cost_1", + "value": None, + }, + { + "type": "entity_not_defined", + "identifier": "sensor.water_cost_1", + "value": None, + }, + ], + [], + [], + [ + { + "type": "entity_unexpected_device_class", + "identifier": "sensor.water_consumption_4", + "value": None, + }, + ], + [ + { + "type": "entity_unexpected_unit_water_price", + "identifier": "sensor.water_price_2", + "value": "EUR/invalid", + }, + ], + ], + "device_consumption": [], + } + + +async def test_validation_water_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): + """Test validating water with sensors without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_1", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + ] + } + ) + hass.states.async_set( + "sensor.water_consumption_1", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 343e814f3a89d2..536077d6b159a4 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -16,7 +16,7 @@ @pytest.fixture(autouse=True) -async def setup_integration(hass, recorder_mock): +async def setup_integration(recorder_mock, hass): """Set up the integration.""" assert await async_setup_component(hass, "energy", {}) @@ -289,7 +289,7 @@ async def test_get_solar_forecast(hass, hass_ws_client, mock_energy_platform) -> @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_no_co2(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_no_co2(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when co2 data is missing.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -450,7 +450,7 @@ async def test_fossil_energy_consumption_no_co2(hass, hass_ws_client, recorder_m @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_hole(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_hole(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when some data points lack sum.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -611,7 +611,7 @@ async def test_fossil_energy_consumption_hole(hass, hass_ws_client, recorder_moc @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_no_data(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_no_data(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when there is no data.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -759,7 +759,7 @@ async def test_fossil_energy_consumption_no_data(hass, hass_ws_client, recorder_ @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption with co2 sensor data.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py new file mode 100644 index 00000000000000..93a76bdd510b70 --- /dev/null +++ b/tests/components/enphase_envoy/conftest.py @@ -0,0 +1,110 @@ +"""Define test fixtures for Enphase Envoy.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.enphase_envoy import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=f"Envoy {serial_number}" if serial_number else "Envoy", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="gateway_data", scope="package") +def gateway_data_fixture(): + """Define a fixture to return gateway data.""" + return json.loads(load_fixture("data.json", "enphase_envoy")) + + +@pytest.fixture(name="inverters_production_data", scope="package") +def inverters_production_data_fixture(): + """Define a fixture to return inverter production data.""" + return json.loads(load_fixture("inverters_production.json", "enphase_envoy")) + + +@pytest.fixture(name="mock_envoy_reader") +def mock_envoy_reader_fixture( + gateway_data, + mock_get_data, + mock_get_full_serial_number, + mock_inverters_production, + serial_number, +): + """Define a mocked EnvoyReader fixture.""" + mock_envoy_reader = Mock( + getData=mock_get_data, + get_full_serial_number=mock_get_full_serial_number, + inverters_production=mock_inverters_production, + ) + + for key, value in gateway_data.items(): + setattr(mock_envoy_reader, key, AsyncMock(return_value=value)) + + return mock_envoy_reader + + +@pytest.fixture(name="mock_get_full_serial_number") +def mock_get_full_serial_number_fixture(serial_number): + """Define a mocked EnvoyReader.get_full_serial_number fixture.""" + return AsyncMock(return_value=serial_number) + + +@pytest.fixture(name="mock_get_data") +def mock_get_data_fixture(): + """Define a mocked EnvoyReader.getData fixture.""" + return AsyncMock() + + +@pytest.fixture(name="mock_inverters_production") +def mock_inverters_production_fixture(inverters_production_data): + """Define a mocked EnvoyReader.inverters_production fixture.""" + return AsyncMock(return_value=inverters_production_data) + + +@pytest.fixture(name="setup_enphase_envoy") +async def setup_enphase_envoy_fixture(hass, config, mock_envoy_reader): + """Define a fixture to set up Enphase Envoy.""" + with patch( + "homeassistant.components.enphase_envoy.config_flow.EnvoyReader", + return_value=mock_envoy_reader, + ), patch( + "homeassistant.components.enphase_envoy.EnvoyReader", + return_value=mock_envoy_reader, + ), patch( + "homeassistant.components.enphase_envoy.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" diff --git a/tests/components/enphase_envoy/fixtures/__init__.py b/tests/components/enphase_envoy/fixtures/__init__.py new file mode 100644 index 00000000000000..b3ef7db17a340d --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define data fixtures for Enphase Envoy.""" diff --git a/tests/components/enphase_envoy/fixtures/data.json b/tests/components/enphase_envoy/fixtures/data.json new file mode 100644 index 00000000000000..d6868a6dbf7df5 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/data.json @@ -0,0 +1,10 @@ +{ + "production": 1840, + "daily_production": 28223, + "seven_days_production": 174482, + "lifetime_production": 5924391, + "consumption": 1840, + "daily_consumption": 5923857, + "seven_days_consumption": 5923857, + "lifetime_consumption": 5923857 +} diff --git a/tests/components/enphase_envoy/fixtures/inverters_production.json b/tests/components/enphase_envoy/fixtures/inverters_production.json new file mode 100644 index 00000000000000..14891f2d27857b --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/inverters_production.json @@ -0,0 +1,18 @@ +{ + "202140024014": [136, "2022-10-08 16:43:36"], + "202140023294": [163, "2022-10-08 16:43:41"], + "202140013819": [130, "2022-10-08 16:43:31"], + "202140023794": [139, "2022-10-08 16:43:38"], + "202140023381": [130, "2022-10-08 16:43:47"], + "202140024176": [54, "2022-10-08 16:43:59"], + "202140003284": [132, "2022-10-08 16:43:55"], + "202140019854": [129, "2022-10-08 16:43:58"], + "202140020743": [131, "2022-10-08 16:43:49"], + "202140023531": [28, "2022-10-08 16:43:53"], + "202140024241": [164, "2022-10-08 16:43:33"], + "202140022963": [164, "2022-10-08 16:43:41"], + "202140023149": [118, "2022-10-08 16:43:47"], + "202140024828": [129, "2022-10-08 16:43:36"], + "202140023269": [133, "2022-10-08 16:43:43"], + "202140024157": [112, "2022-10-08 16:43:52"] +} diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index caba229692774e..fac5b01c60e376 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,46 +1,31 @@ """Test the Enphase Envoy config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx +import pytest from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import DOMAIN -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, config, setup_enphase_envoy) -> None: """Test we get the form.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - return_value="1234", - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy 1234" assert result2["data"] == { @@ -49,38 +34,27 @@ async def test_form(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_no_serial_number(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("serial_number", [None]) +async def test_user_no_serial_number( + hass: HomeAssistant, config, setup_enphase_envoy +) -> None: """Test user setup without a serial number.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - return_value=None, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy" assert result2["data"] == { @@ -89,40 +63,36 @@ async def test_user_no_serial_number(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_fetching_serial_fails(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_full_serial_number", + [ + AsyncMock( + side_effect=httpx.HTTPStatusError( + "any", request=MagicMock(), response=MagicMock() + ) + ) + ], +) +async def test_user_fetching_serial_fails( + hass: HomeAssistant, setup_enphase_envoy +) -> None: """Test user setup without a serial number.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ), - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy" assert result2["data"] == { @@ -131,83 +101,75 @@ async def test_user_fetching_serial_fails(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_data", + [ + AsyncMock( + side_effect=httpx.HTTPStatusError( + "any", request=MagicMock(), response=MagicMock() + ) + ) + ], +) +async def test_form_invalid_auth(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_data", [AsyncMock(side_effect=httpx.HTTPError("any"))] +) +async def test_form_cannot_connect(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=httpx.HTTPError("any"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("mock_get_data", [AsyncMock(side_effect=ValueError)]) +async def test_form_unknown_error(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=ValueError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we can setup from zeroconf.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -221,28 +183,17 @@ async def test_zeroconf(hass: HomeAssistant) -> None: type="mock_type", ), ) - await hass.async_block_till_done() - assert result["type"] == "form" assert result["step_id"] == "user" - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy 1234" assert result2["result"].unique_id == "1234" @@ -252,63 +203,34 @@ async def test_zeroconf(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_host_already_exists(hass: HomeAssistant) -> None: +async def test_form_host_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: """Test host already exists.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", - ) - config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: - """Test serial number already exists from zeroconf.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "host": "1.1.1.1", - "name": "Envoy", "username": "test-username", "password": "test-password", }, - unique_id="1234", - title="Envoy", ) - config_entry.add_to_hass(hass) + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + +async def test_zeroconf_serial_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test serial number already exists from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -322,28 +244,16 @@ async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: type="mock_type", ), ) - assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "4.4.4.4" + assert config_entry.data["host"] == "4.4.4.4" -async def test_zeroconf_serial_already_exists_ignores_ipv6(hass: HomeAssistant) -> None: - """Test serial number already exists from zeroconf but the discovery is ipv6.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - unique_id="1234", - title="Envoy", - ) - config_entry.add_to_hass(hass) +async def test_zeroconf_serial_already_exists_ignores_ipv6( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test serial number already exists from zeroconf but the discovery is ipv6.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -357,71 +267,39 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6(hass: HomeAssistant) type="mock_type", ), ) - assert result["type"] == "abort" assert result["reason"] == "not_ipv4_address" - assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data["host"] == "1.1.1.1" -async def test_zeroconf_host_already_exists(hass: HomeAssistant) -> None: - """Test hosts already exists from zeroconf.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", +@pytest.mark.parametrize("serial_number", [None]) +async def test_zeroconf_host_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test hosts already exists from zeroconf.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + addresses=["1.1.1.1"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234"}, - type="mock_type", - ), - ) - await hass.async_block_till_done() - assert result["type"] == "abort" assert result["reason"] == "already_configured" assert config_entry.unique_id == "1234" assert config_entry.title == "Envoy 1234" - assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, config_entry, setup_enphase_envoy) -> None: """Test we reauth auth.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", - ) - config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ @@ -430,19 +308,13 @@ async def test_reauth(hass: HomeAssistant) -> None: "entry_id": config_entry.entry_id, }, ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "abort" assert result2["reason"] == "reauth_successful" diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py new file mode 100644 index 00000000000000..caa7d66fc95c73 --- /dev/null +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Test Enphase Envoy diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_enphase_envoy): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "enphase_envoy", + "title": REDACTED, + "data": { + "host": "1.1.1.1", + "name": REDACTED, + "username": REDACTED, + "password": REDACTED, + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + "data": { + "production": 1840, + "daily_production": 28223, + "seven_days_production": 174482, + "lifetime_production": 5924391, + "consumption": 1840, + "daily_consumption": 5923857, + "seven_days_consumption": 5923857, + "lifetime_consumption": 5923857, + "inverters_production": { + "202140024014": [136, "2022-10-08 16:43:36"], + "202140023294": [163, "2022-10-08 16:43:41"], + "202140013819": [130, "2022-10-08 16:43:31"], + "202140023794": [139, "2022-10-08 16:43:38"], + "202140023381": [130, "2022-10-08 16:43:47"], + "202140024176": [54, "2022-10-08 16:43:59"], + "202140003284": [132, "2022-10-08 16:43:55"], + "202140019854": [129, "2022-10-08 16:43:58"], + "202140020743": [131, "2022-10-08 16:43:49"], + "202140023531": [28, "2022-10-08 16:43:53"], + "202140024241": [164, "2022-10-08 16:43:33"], + "202140022963": [164, "2022-10-08 16:43:41"], + "202140023149": [118, "2022-10-08 16:43:47"], + "202140024828": [129, "2022-10-08 16:43:36"], + "202140023269": [133, "2022-10-08 16:43:43"], + "202140024157": [112, "2022-10-08 16:43:52"], + }, + }, + } diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index 604f5e3a2e9785..9a4dc14685ac60 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test fan registered attributes to be excluded.""" await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index e6c42d370e6a61..b440ac7889b262 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -59,7 +59,7 @@ async def test_setup_fail(hass): await hass.async_block_till_done() -async def test_chain(hass, recorder_mock, values): +async def test_chain(recorder_mock, hass, values): """Test if filter chaining works.""" config = { "sensor": { @@ -87,7 +87,7 @@ async def test_chain(hass, recorder_mock, values): @pytest.mark.parametrize("missing", (True, False)) -async def test_chain_history(hass, recorder_mock, values, missing): +async def test_chain_history(recorder_mock, hass, values, missing): """Test if filter chaining works, when a source is and isn't recorded.""" config = { "sensor": { @@ -141,7 +141,7 @@ async def test_chain_history(hass, recorder_mock, values, missing): assert state.state == "17.05" -async def test_source_state_none(hass, recorder_mock, values): +async def test_source_state_none(recorder_mock, hass, values): """Test is source sensor state is null and sets state to STATE_UNKNOWN.""" config = { @@ -201,7 +201,7 @@ async def test_source_state_none(hass, recorder_mock, values): assert state.state == STATE_UNKNOWN -async def test_history_time(hass, recorder_mock): +async def test_history_time(recorder_mock, hass): """Test loading from history based on a time window.""" config = { "sensor": { @@ -239,7 +239,7 @@ async def test_history_time(hass, recorder_mock): assert state.state == "18.0" -async def test_setup(hass, recorder_mock): +async def test_setup(recorder_mock, hass): """Test if filter attributes are inherited.""" config = { "sensor": { @@ -280,7 +280,7 @@ async def test_setup(hass, recorder_mock): assert entity_id == "sensor.test" -async def test_invalid_state(hass, recorder_mock): +async def test_invalid_state(recorder_mock, hass): """Test if filter attributes are inherited.""" config = { "sensor": { @@ -310,7 +310,7 @@ async def test_invalid_state(hass, recorder_mock): assert state.state == STATE_UNAVAILABLE -async def test_timestamp_state(hass, recorder_mock): +async def test_timestamp_state(recorder_mock, hass): """Test if filter state is a datetime.""" config = { "sensor": { @@ -469,7 +469,7 @@ def test_time_sma(values): assert filtered.state == 21.5 -async def test_reload(hass, recorder_mock): +async def test_reload(recorder_mock, hass): """Verify we can reload filter sensors.""" hass.states.async_set("sensor.test_monitored", 12345) await async_setup_component( diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py index 94acad4df5a3f0..d4014ea8657a08 100644 --- a/tests/components/fjaraskupan/__init__.py +++ b/tests/components/fjaraskupan/__init__.py @@ -2,10 +2,11 @@ from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( name="COOKERHOOD_FJAR", address="AA:BB:CC:DD:EE:FF", @@ -15,7 +16,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 30468064daed8c..1b8a1928b1fa9b 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -69,7 +69,7 @@ async def test_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.flipr_myfliprid_water_temp") assert state assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is TEMP_CELSIUS + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "10.5" diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index c3266a84bd8b35..63cd2b97e70569 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -3,12 +3,14 @@ from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .common import TEST_PASSWORD, TEST_USER_ID async def test_sensors(hass, config_entry, aioclient_mock_fixture): """Test Flo by Moen sensors.""" + hass.config.units = US_CUSTOMARY_SYSTEM config_entry.add_to_hass(hass) assert await async_setup_component( hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} @@ -49,7 +51,7 @@ async def test_sensors(hass, config_entry, aioclient_mock_fixture): == SensorStateClass.MEASUREMENT ) - assert hass.states.get("sensor.smart_water_shutoff_water_temperature").state == "21" + assert hass.states.get("sensor.smart_water_shutoff_water_temperature").state == "70" assert ( hass.states.get("sensor.smart_water_shutoff_water_temperature").attributes[ ATTR_STATE_CLASS @@ -58,7 +60,7 @@ async def test_sensors(hass, config_entry, aioclient_mock_fixture): ) # and 3 entities for the detector - assert hass.states.get("sensor.kitchen_sink_temperature").state == "16" + assert hass.states.get("sensor.kitchen_sink_temperature").state == "61" assert ( hass.states.get("sensor.kitchen_sink_temperature").attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index ba0473513b9f04..957c52a88c5f46 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -16,7 +16,7 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.setup import async_setup_component -TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" +TEST_MASTER_ENTITY_NAME = "media_player.owntone_server" async def test_async_browse_media(hass, hass_ws_client, config_entry): @@ -255,7 +255,7 @@ async def test_async_browse_spotify(hass, hass_ws_client, config_entry): assert await async_setup_component(hass, spotify.DOMAIN, {}) await hass.async_block_till_done() config_entry.add_to_hass(hass) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( "homeassistant.components.forked_daapd.media_player.spotify_async_browse_media" @@ -299,6 +299,52 @@ async def test_async_browse_spotify(hass, hass_ws_client, config_entry): assert msg["success"] +async def test_async_browse_media_source(hass, hass_ws_client, config_entry): + """Test browsing media_source.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + with patch( + "homeassistant.components.forked_daapd.media_player.media_source.async_browse_media" + ) as mock_media_source_browse: + children = [ + BrowseMedia( + title="Test mp3", + media_class=MediaClass.MUSIC, + media_content_id="media-source://test_dir/test.mp3", + media_content_type="audio/aac", + can_play=False, + can_expand=True, + ) + ] + mock_media_source_browse.return_value = BrowseMedia( + title="Audio Folder", + media_class=MediaClass.DIRECTORY, + media_content_id="media-source://audio_folder", + media_content_type=MediaType.APP, + can_play=False, + can_expand=True, + children=children, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": TEST_MASTER_ENTITY_NAME, + "media_content_type": MediaType.APP, + "media_content_id": "media-source://audio_folder", + } + ) + msg = await client.receive_json() + # Assert WebSocket response + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + async def test_async_browse_image(hass, hass_client, config_entry): """Test browse media images.""" diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 589f176db14335..ae5e29bee472ed 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -66,10 +66,9 @@ from tests.common import async_mock_signal -TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" +TEST_MASTER_ENTITY_NAME = "media_player.owntone_server" TEST_ZONE_ENTITY_NAMES = [ - "media_player.forked_daapd_output_" + x - for x in ("kitchen", "computer", "daapd_fifo") + "media_player.owntone_output_" + x for x in ("kitchen", "computer", "daapd_fifo") ] OPTIONS_DATA = { @@ -354,7 +353,7 @@ def test_master_state(hass, mock_api_object): """Test master state attributes.""" state = hass.states.get(TEST_MASTER_ENTITY_NAME) assert state.state == STATE_PAUSED - assert state.attributes[ATTR_FRIENDLY_NAME] == "forked-daapd server" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Owntone server" assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 @@ -413,7 +412,7 @@ async def test_zone(hass, mock_api_object): """Test zone attributes and methods.""" zone_entity_name = TEST_ZONE_ENTITY_NAMES[0] state = hass.states.get(zone_entity_name) - assert state.attributes[ATTR_FRIENDLY_NAME] == "forked-daapd output (kitchen)" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Owntone output (kitchen)" assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES_ZONE assert state.state == STATE_ON assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 0759beb68498c9..8078722246e1e3 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,6 +1,6 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, call from requests.exceptions import HTTPError @@ -11,8 +11,10 @@ ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, DOMAIN, ) from homeassistant.const import ( @@ -24,7 +26,6 @@ STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.util import color import homeassistant.util.dt as dt_util from . import FritzDeviceLightMock, setup_config_entry @@ -53,9 +54,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_TEMP] == color.color_temperature_kelvin_to_mired( - 2700 - ) + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 async def test_setup_color(hass: HomeAssistant, fritz: Mock): @@ -95,12 +96,14 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock): assert await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP: 300}, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 3000}, True, ) assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 + assert device.set_color_temp.call_args_list == [call(3000)] + assert device.set_level.call_args_list == [call(100)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock): @@ -122,6 +125,10 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock): assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 + assert device.set_level.call_args_list == [call(100)] + assert device.set_unmapped_color.call_args_list == [ + call((100, round(70 * 255.0 / 100.0))) + ] async def test_turn_on_color_unsupported_api_method(hass: HomeAssistant, fritz: Mock): @@ -150,6 +157,8 @@ async def test_turn_on_color_unsupported_api_method(hass: HomeAssistant, fritz: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 + assert device.set_level.call_args_list == [call(100)] + assert device.set_color.call_args_list == [call((100, 70))] async def test_turn_off(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index 8616d7107f7c3a..1c839e57dfd13c 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -22,31 +22,56 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_restart_browser") assert entry assert entry.unique_id == "abcdef-123456-restartApp" - await call_service(hass, "press", "button.amazon_fire_restart_browser") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_restart_browser"}, + blocking=True, + ) assert len(mock_fully_kiosk.restartApp.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_reboot_device") assert entry assert entry.unique_id == "abcdef-123456-rebootDevice" - await call_service(hass, "press", "button.amazon_fire_reboot_device") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_reboot_device"}, + blocking=True, + ) assert len(mock_fully_kiosk.rebootDevice.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_bring_to_foreground") assert entry assert entry.unique_id == "abcdef-123456-toForeground" - await call_service(hass, "press", "button.amazon_fire_bring_to_foreground") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_bring_to_foreground"}, + blocking=True, + ) assert len(mock_fully_kiosk.toForeground.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_send_to_background") assert entry assert entry.unique_id == "abcdef-123456-toBackground" - await call_service(hass, "press", "button.amazon_fire_send_to_background") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_send_to_background"}, + blocking=True, + ) assert len(mock_fully_kiosk.toBackground.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_load_start_url") assert entry assert entry.unique_id == "abcdef-123456-loadStartUrl" - await call_service(hass, "press", "button.amazon_fire_load_start_url") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_load_start_url"}, + blocking=True, + ) assert len(mock_fully_kiosk.loadStartUrl.mock_calls) == 1 assert entry.device_id @@ -60,10 +85,3 @@ async def test_buttons( assert device_entry.model == "KFDOWI" assert device_entry.name == "Amazon Fire" assert device_entry.sw_version == "1.42.5" - - -def call_service(hass, service, entity_id): - """Call any service on entity.""" - return hass.services.async_call( - button.DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py new file mode 100644 index 00000000000000..386bc542e3cc89 --- /dev/null +++ b/tests/components/fully_kiosk/test_services.py @@ -0,0 +1,47 @@ +"""Test Fully Kiosk Browser services.""" +from unittest.mock import MagicMock + +from homeassistant.components.fully_kiosk.const import ( + ATTR_APPLICATION, + ATTR_URL, + DOMAIN, + SERVICE_LOAD_URL, + SERVICE_START_APPLICATION, +) +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_services( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the Fully Kiosk Browser services.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdef-123456")} + ) + + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_LOAD_URL, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_URL: "https://example.com"}, + blocking=True, + ) + + assert len(mock_fully_kiosk.loadUrl.mock_calls) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_START_APPLICATION, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_APPLICATION: "de.ozerov.fully"}, + blocking=True, + ) + + assert len(mock_fully_kiosk.startApplication.mock_calls) == 1 diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index d55141ebe5651f..83b9efde1e8ea3 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -34,7 +34,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -208,7 +208,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( "1234", diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 808e858b25972d..74679f050b6f4e 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -78,7 +78,6 @@ def mock_create_stream(): @pytest.fixture async def user_flow(hass): """Initiate a user flow.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index f7e1898f735134..04c7fcca5b5c5b 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -20,6 +20,7 @@ CONF_STREAM_SOURCE, DOMAIN, ) +from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL @@ -209,6 +210,7 @@ async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png CONF_VERIFY_SSL: False, CONF_USERNAME: "barney", CONF_PASSWORD: "betty", + CONF_RTSP_TRANSPORT: "http", }, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index d303e064c1f91c..ba4ff4dbd0c76f 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -1,8 +1,9 @@ """Test The generic (IP Camera) config flow.""" import errno +from http import HTTPStatus import os.path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import httpx import pytest @@ -12,6 +13,7 @@ from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( + CONF_CONFIRMED_OK, CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, @@ -58,16 +60,30 @@ @respx.mock -async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): +async def test_form(hass, fakeimgbytes_png, hass_client, user_flow, mock_create_stream): """Test the form with a normal set of settings.""" + respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with mock_create_stream as mock_setup, patch( "homeassistant.components.generic.async_setup_entry", return_value=True ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, ) + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + client = await hass_client() + preview_id = result1["flow_id"] + # Check the preview image works. + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}?t=1") + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -83,6 +99,9 @@ async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): } await hass.async_block_till_done() + # Check that the preview image is disabled after. + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}") + assert resp.status == HTTPStatus.NOT_FOUND assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -99,11 +118,17 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) await hass.async_block_till_done() + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -120,16 +145,65 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): assert respx.calls.call_count == 1 +@respx.mock +async def test_form_reject_still_preview( + hass, fakeimgbytes_png, mock_create_stream, user_flow +): + """Test we go back to the config screen if the user rejects the still preview.""" + respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) + with mock_create_stream: + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: False}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + + +@respx.mock +async def test_form_still_preview_cam_off( + hass, fakeimg_png, mock_create_stream, user_flow, hass_client +): + """Test camera errors are triggered during preview.""" + with patch( + "homeassistant.components.generic.camera.GenericCamera.is_on", + new_callable=PropertyMock(return_value=False), + ), mock_create_stream: + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + preview_id = result1["flow_id"] + # Try to view the image, should be unavailable. + client = await hass_client() + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}?t=1") + assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE + + @respx.mock async def test_form_only_stillimage_gif(hass, fakeimg_gif, user_flow): """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @@ -143,11 +217,17 @@ async def test_form_only_svg_whitespace(hass, fakeimgbytes_svg, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) - await hass.async_block_till_done() + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -170,10 +250,16 @@ async def test_form_only_still_sample(hass, user_flow, image_file): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -186,31 +272,31 @@ async def test_form_only_still_sample(hass, user_flow, image_file): ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", - data_entry_flow.FlowResultType.CREATE_ENTRY, + "user_confirm_still", None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", - data_entry_flow.FlowResultType.CREATE_ENTRY, + "user_confirm_still", None, ), ( "http://{{example.org", "http://example.org", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "template_error"}, ), ( "invalid1://invalid:4\\1", "invalid1://invalid:4%5c1", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "malformed_url"}, ), ( "relative/urls/are/not/allowed.jpg", "relative/urls/are/not/allowed.jpg", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "relative_url"}, ), ], @@ -229,7 +315,7 @@ async def test_still_template( data, ) await hass.async_block_till_done() - assert result2["type"] == expected_result + assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -242,10 +328,15 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): with mock_create_stream as mock_setup, patch( "homeassistant.components.generic.async_setup_entry", return_value=True ): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) - assert "errors" not in result2, f"errors={result2['errors']}" + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -265,21 +356,23 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): assert len(mock_setup.mock_calls) == 1 -async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): +async def test_form_only_stream(hass, fakeimgbytes_jpg, user_flow, mock_create_stream): """Test we complete ok if the user wants stream only.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" with mock_create_stream as mock_setup: - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result3 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result3["title"] == "127_0_0_1" assert result3["options"] == { @@ -503,7 +596,13 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream result["flow_id"], user_input=data, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "confirm_still" + + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) assert result3["type"] == data_entry_flow.FlowResultType.FORM @@ -588,10 +687,16 @@ async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): # try updating the config options with mock_create_stream: - result3 = await hass.config_entries.options.async_configure( + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, ) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "confirm_still" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" @@ -716,4 +821,24 @@ async def test_use_wallclock_as_timestamps_option( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.FORM + # Test what happens if user rejects the preview + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} + ) + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["step_id"] == "init" + with patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ), mock_create_stream: + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["step_id"] == "confirm_still" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + assert result5["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index f6a8795a29db95..52714ab15a2d03 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -37,7 +37,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( assert_setup_component, @@ -1201,7 +1201,7 @@ async def setup_comp_9(hass): async def test_precision(hass, setup_comp_9): """Test that setting precision to tenths works as intended.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await common.async_set_temperature(hass, 23.27) state = hass.states.get(ENTITY) assert state.attributes.get("temperature") == 23.3 diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 130964b4eebe46..327829d3d4bc26 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -28,7 +28,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -171,7 +171,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (38.0, -3.0)) diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index dbb6834c596245..9b53cb9cc9b014 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -23,7 +23,7 @@ ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -150,7 +150,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 1, 15.5, (38.0, -3.0)) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index e6b7c26ca84896..2f5efd829bf983 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -206,9 +206,13 @@ def _put_result( ) -> None: if calendar_id is None: calendar_id = CALENDAR_ID + resp = { + **response, + "nextSyncToken": "sync-token", + } aioclient_mock.get( f"{API_BASE_URL}/calendars/{calendar_id}/events", - json=response, + json=resp, exc=exc, ) return @@ -236,9 +240,13 @@ def mock_calendars_list( """Fixture to construct a fake calendar list API response.""" def _result(response: dict[str, Any], exc: ClientError | None = None) -> None: + resp = { + **response, + "nextSyncToken": "sync-token", + } aioclient_mock.get( f"{API_BASE_URL}/users/me/calendarList", - json=response, + json=resp, exc=exc, ) return diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index f4129eb0926716..3bd584f4c6fa72 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -2,7 +2,6 @@ from __future__ import annotations -import copy import datetime from http import HTTPStatus from typing import Any @@ -10,7 +9,6 @@ import urllib from aiohttp.client_exceptions import ClientError -from gcal_sync.auth import API_BASE_URL import pytest from homeassistant.components.google.const import DOMAIN @@ -24,10 +22,10 @@ TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, + TEST_YAML_ENTITY_NAME, ) from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME @@ -75,6 +73,11 @@ def mock_test_setup( return +def get_events_url(entity: str, start: str, end: str) -> str: + """Create a url to get events during the specified time range.""" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + + def upcoming() -> dict[str, Any]: """Create a test event with an arbitrary start/end time fetched from the api url.""" now = dt_util.now() @@ -84,21 +87,12 @@ def upcoming() -> dict[str, Any]: } -def upcoming_date() -> dict[str, Any]: - """Create a test event with an arbitrary start/end date fetched from the api url.""" - now = dt_util.now() - return { - "start": {"date": now.date().isoformat()}, - "end": {"date": now.date().isoformat()}, - } - - def upcoming_event_url(entity: str = TEST_ENTITY) -> str: """Return a calendar API to return events created by upcoming().""" now = dt_util.now() start = (now - datetime.timedelta(minutes=60)).isoformat() end = (now + datetime.timedelta(minutes=60)).isoformat() - return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + return get_events_url(entity, start, end) async def test_all_day_event(hass, mock_events_list_items, component_setup): @@ -414,14 +408,12 @@ async def test_http_event_api_failure( aioclient_mock, ): """Test the Rest API response during a calendar failure.""" - mock_events_list({}) + mock_events_list({}, exc=ClientError()) + assert await component_setup() client = await hass_client() - aioclient_mock.clear_requests() - mock_events_list({}, exc=ClientError()) - response = await client.get(upcoming_event_url()) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR @@ -462,7 +454,8 @@ async def test_http_api_all_day_event( """Test querying the API and fetching events from the server.""" event = { **TEST_EVENT, - **upcoming_date(), + "start": {"date": "2022-03-27"}, + "end": {"date": "2022-03-28"}, } mock_events_list_items([event]) assert await component_setup() @@ -475,70 +468,10 @@ async def test_http_api_all_day_event( assert {k: events[0].get(k) for k in ["summary", "start", "end"]} == { "summary": TEST_EVENT["summary"], "start": {"date": "2022-03-27"}, - "end": {"date": "2022-03-27"}, + "end": {"date": "2022-03-28"}, } -@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00") -async def test_http_api_event_paging( - hass, hass_client, aioclient_mock, component_setup -): - """Test paging through results from the server.""" - hass.config.set_time_zone("Asia/Baghdad") - - responses = [ - { - "nextPageToken": "page-token", - "items": [ - { - **TEST_EVENT, - "summary": "event 1", - **upcoming(), - } - ], - }, - { - "items": [ - { - **TEST_EVENT, - "summary": "event 2", - **upcoming(), - } - ], - }, - ] - - def next_response(response_list): - results = copy.copy(response_list) - - async def get(method, url, data): - return AiohttpClientMockResponse(method, url, json=results.pop(0)) - - return get - - # Setup response for initial entity load - aioclient_mock.get( - f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events", - side_effect=next_response(responses), - ) - assert await component_setup() - - # Setup response for API request - aioclient_mock.clear_requests() - aioclient_mock.get( - f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events", - side_effect=next_response(responses), - ) - - client = await hass_client() - response = await client.get(upcoming_event_url()) - assert response.status == HTTPStatus.OK - events = await response.json() - assert len(events) == 2 - assert events[0]["summary"] == "event 1" - assert events[1]["summary"] == "event 2" - - @pytest.mark.parametrize( "calendars_config_ignore_availability,transparency,expect_visible_event", [ @@ -577,6 +510,11 @@ async def test_opaque_event( events = await response.json() assert (len(events) > 0) == expect_visible_event + # Verify entity state for upcoming event + state = hass.states.get(TEST_YAML_ENTITY) + assert state.name == TEST_YAML_ENTITY_NAME + assert state.state == (STATE_ON if expect_visible_event else STATE_OFF) + @pytest.mark.parametrize("mock_test_setup", [None]) async def test_scan_calendar_error( @@ -783,3 +721,58 @@ async def test_invalid_unique_id_cleanup( entity_registry, config_entry.entry_id ) assert not registry_entries + + +@pytest.mark.parametrize( + "time_zone,event_order", + [ + ("America/Los_Angeles", ["One", "Two", "All Day Event"]), + ("America/Regina", ["One", "Two", "All Day Event"]), + ("UTC", ["One", "All Day Event", "Two"]), + ("Asia/Tokyo", ["All Day Event", "One", "Two"]), + ], +) +async def test_all_day_iter_order( + hass, + hass_client, + mock_events_list_items, + component_setup, + time_zone, + event_order, +): + """Test the sort order of an all day events depending on the time zone.""" + hass.config.set_time_zone(time_zone) + mock_events_list_items( + [ + { + **TEST_EVENT, + "id": "event-id-3", + "summary": "All Day Event", + "start": {"date": "2022-10-08"}, + "end": {"date": "2022-10-09"}, + }, + { + **TEST_EVENT, + "id": "event-id-1", + "summary": "One", + "start": {"dateTime": "2022-10-07T23:00:00+00:00"}, + "end": {"dateTime": "2022-10-07T23:30:00+00:00"}, + }, + { + **TEST_EVENT, + "id": "event-id-2", + "summary": "Two", + "start": {"dateTime": "2022-10-08T01:00:00+00:00"}, + "end": {"dateTime": "2022-10-08T02:00:00+00:00"}, + }, + ] + ) + assert await component_setup() + + client = await hass_client() + response = await client.get( + get_events_url(TEST_ENTITY, "2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert [event["summary"] for event in events] == event_order diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 613aa6dbb707fd..5e7696eec6881e 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -818,3 +818,24 @@ async def test_assign_unique_id_failure( assert config_entry.state is config_entry_status assert config_entry.unique_id is None + + +async def test_remove_entry( + hass: HomeAssistant, + mock_calendars_list: ApiResult, + component_setup: ComponentSetup, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, +) -> None: + """Test load and remove of a ConfigEntry.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_remove(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 4ca7c5a91055fe..ec5a8f1691745d 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -1,7 +1,7 @@ """Fixtures for Google Time Travel tests.""" from unittest.mock import patch -from googlemaps.exceptions import ApiError +from googlemaps.exceptions import ApiError, Timeout, TransportError import pytest from homeassistant.components.google_travel_time.const import DOMAIN @@ -58,3 +58,21 @@ def validate_config_entry_fixture(): def invalidate_config_entry_fixture(validate_config_entry): """Return invalid config entry.""" validate_config_entry.side_effect = ApiError("test") + + +@pytest.fixture(name="invalid_api_key") +def invalid_api_key_fixture(validate_config_entry): + """Throw a REQUEST_DENIED ApiError.""" + validate_config_entry.side_effect = ApiError("REQUEST_DENIED", "Invalid API key.") + + +@pytest.fixture(name="timeout") +def timeout_fixture(validate_config_entry): + """Throw a Timeout exception.""" + validate_config_entry.side_effect = Timeout() + + +@pytest.fixture(name="transport_error") +def transport_error_fixture(validate_config_entry): + """Throw a TransportError exception.""" + validate_config_entry.side_effect = TransportError("Unknown.") diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 4fe5f797d4522e..9ddcee5cdacf8d 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -19,13 +19,9 @@ DEFAULT_NAME, DEPARTURE_TIME, DOMAIN, + UNITS_IMPERIAL, ) -from homeassistant.const import ( - CONF_API_KEY, - CONF_MODE, - CONF_NAME, - CONF_UNIT_SYSTEM_IMPERIAL, -) +from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME from .const import MOCK_CONFIG @@ -71,6 +67,73 @@ async def test_invalid_config_entry(hass): assert result2["errors"] == {"base": "cannot_connect"} +@pytest.mark.usefixtures("invalid_api_key") +async def test_invalid_api_key(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.usefixtures("transport_error") +async def test_transport_error(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("timeout") +async def test_timeout(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_malformed_api_key(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + @pytest.mark.parametrize( "data,options", [ @@ -78,8 +141,7 @@ async def test_invalid_config_entry(hass): MOCK_CONFIG, { CONF_MODE: "driving", - CONF_ARRIVAL_TIME: "test", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, }, ) ], @@ -100,7 +162,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, CONF_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", @@ -114,7 +176,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_ARRIVAL_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -125,7 +187,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_ARRIVAL_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -135,7 +197,15 @@ async def test_options_flow(hass, mock_config): @pytest.mark.parametrize( "data,options", - [(MOCK_CONFIG, {})], + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], ) @pytest.mark.usefixtures("validate_config_entry") async def test_options_flow_departure_time(hass, mock_config): @@ -153,7 +223,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: DEPARTURE_TIME, CONF_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", @@ -167,7 +237,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_DEPARTURE_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -178,7 +248,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_DEPARTURE_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", diff --git a/tests/components/google_travel_time/test_init.py b/tests/components/google_travel_time/test_init.py deleted file mode 100644 index 583cd4dc7ce994..00000000000000 --- a/tests/components/google_travel_time/test_init.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test Google Maps Travel Time initialization.""" -from homeassistant.components.google_travel_time.const import DOMAIN -from homeassistant.helpers.entity_registry import async_get - -from tests.common import MockConfigEntry - - -async def test_migration(hass, bypass_platform_setup): - """Test migration logic for unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, version=1, entry_id="test", unique_id="test" - ) - ent_reg = async_get(hass) - ent_entry = ent_reg.async_get_or_create( - "sensor", DOMAIN, unique_id="replaceable_unique_id", config_entry=config_entry - ) - entity_id = ent_entry.entity_id - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id is None - assert ent_reg.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index daedcfef4c18f4..d0a94712fcbd16 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -4,12 +4,19 @@ import pytest +from homeassistant.components.google_travel_time.config_flow import default_options from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, - CONF_TRAVEL_MODE, DOMAIN, ) +from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .const import MOCK_CONFIG @@ -195,26 +202,33 @@ async def test_sensor_arrival_time_custom_timestamp(hass): assert hass.states.get("sensor.google_travel_time").state == "27" -@pytest.mark.usefixtures("mock_update") -async def test_sensor_deprecation_warning(hass, caplog): - """Test that sensor setup prints a deprecating warning for old configs. +@pytest.mark.parametrize( + "unit_system, expected_unit_option", + [ + (METRIC_SYSTEM, CONF_UNIT_SYSTEM_METRIC), + (US_CUSTOMARY_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL), + ], +) +async def test_sensor_unit_system( + hass: HomeAssistant, + unit_system: UnitSystem, + expected_unit_option: str, +) -> None: + """Test that sensor works.""" + hass.config.units = unit_system - The mock_config fixture does not work with caplog. - """ - data = MOCK_CONFIG.copy() - data[CONF_TRAVEL_MODE] = "driving" config_entry = MockConfigEntry( domain=DOMAIN, - data=data, + data=MOCK_CONFIG, + options=default_options(hass), entry_id="test", ) config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.google_travel_time.sensor.Client"), patch( + "homeassistant.components.google_travel_time.sensor.distance_matrix" + ) as distance_matrix_mock: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert hass.states.get("sensor.google_travel_time").state == "27" - wstr = ( - "Google Travel Time: travel_mode is deprecated, please " - "add mode to the options dictionary instead!" - ) - assert wstr in caplog.text + distance_matrix_mock.assert_called_once() + assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option diff --git a/tests/components/graphite/test_init.py b/tests/components/graphite/test_init.py index 23a25b1623e551..19c9ebd61e381b 100644 --- a/tests/components/graphite/test_init.py +++ b/tests/components/graphite/test_init.py @@ -1,231 +1,281 @@ """The tests for the Graphite component.""" +import asyncio import socket -import unittest from unittest import mock from unittest.mock import patch -import homeassistant.components.graphite as graphite -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, - STATE_OFF, - STATE_ON, +import pytest + +from homeassistant.components import graphite +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + + +@pytest.fixture(name="mock_gf") +def fixture_mock_gf(): + """Mock Graphite Feeder fixture.""" + with patch("homeassistant.components.graphite.GraphiteFeeder") as mock_gf: + yield mock_gf + + +@pytest.fixture(name="mock_socket") +def fixture_mock_socket(): + """Mock socket fixture.""" + with patch("socket.socket") as mock_socket: + yield mock_socket + + +@pytest.fixture(name="mock_time") +def fixture_mock_time(): + """Mock time fixture.""" + with patch("time.time") as mock_time: + yield mock_time + + +async def test_setup(hass, mock_socket): + """Test setup.""" + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_setup_failure(hass, mock_socket): + """Test setup fails due to socket error.""" + mock_socket.return_value.connect.side_effect = OSError + assert not await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + assert mock_socket.return_value.connect.call_count == 1 + + +async def test_full_config(hass, mock_gf, mock_socket): + """Test setup with full configuration.""" + config = {"graphite": {"host": "foo", "port": 123, "prefix": "me"}} + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.call_count == 1 + assert mock_gf.call_args == mock.call(hass, "foo", 123, "tcp", "me") + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_full_udp_config(hass, mock_gf, mock_socket): + """Test setup with full configuration and UDP protocol.""" + config = { + "graphite": {"host": "foo", "port": 123, "protocol": "udp", "prefix": "me"} + } + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.call_count == 1 + assert mock_gf.call_args == mock.call(hass, "foo", 123, "udp", "me") + assert mock_socket.call_count == 0 + + +async def test_config_port(hass, mock_gf, mock_socket): + """Test setup with invalid port.""" + config = {"graphite": {"host": "foo", "port": 2003}} + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.called + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_start(hass, mock_socket, mock_time): + """Test the start.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + b"ha.test.entity.state 1.000000 12345" + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +async def test_shutdown(hass, mock_socket, mock_time): + """Test the shutdown.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + b"ha.test.entity.state 1.000000 12345" + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + await hass.async_stop() + await hass.async_block_till_done() + + hass.states.async_set("test.entity", STATE_OFF) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 0 + assert mock_socket.return_value.sendall.call_count == 0 + + +async def test_report_attributes(hass, mock_socket, mock_time): + """Test the reporting with attributes.""" + attrs = {"foo": 1, "bar": 2.0, "baz": True, "bat": "NaN"} + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.bar 2.000000 12345", + "ha.test.entity.baz 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON, attrs) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +async def test_report_with_string_state(hass, mock_socket, mock_time): + """Test the reporting with strings.""" + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", "above_horizon", {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + hass.states.async_set("test.entity", "not_float") + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 0 + assert mock_socket.return_value.sendall.call_count == 0 + assert mock_socket.return_value.send.call_count == 0 + assert mock_socket.return_value.close.call_count == 0 + + +async def test_report_with_binary_state(hass, mock_socket, mock_time): + """Test the reporting with binary state.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + hass.states.async_set("test.entity", STATE_ON, {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 0.000000 12345", + ] + hass.states.async_set("test.entity", STATE_OFF, {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +@pytest.mark.parametrize( + "error, log_text", + [ + (OSError, "Failed to send data to graphite"), + (socket.gaierror, "Unable to connect to host"), + (Exception, "Failed to process STATE_CHANGED event"), + ], ) -import homeassistant.core as ha -from homeassistant.setup import setup_component - -from tests.common import get_test_home_assistant - - -class TestGraphite(unittest.TestCase): - """Test the Graphite component.""" - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.gf = graphite.GraphiteFeeder(self.hass, "foo", 123, "tcp", "ha") - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - @patch("socket.socket") - def test_setup(self, mock_socket): - """Test setup.""" - assert setup_component(self.hass, graphite.DOMAIN, {"graphite": {}}) - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_full_config(self, mock_gf, mock_socket): - """Test setup with full configuration.""" - config = {"graphite": {"host": "foo", "port": 123, "prefix": "me"}} - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.call_count == 1 - assert mock_gf.call_args == mock.call(self.hass, "foo", 123, "tcp", "me") - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_full_udp_config(self, mock_gf, mock_socket): - """Test setup with full configuration and UDP protocol.""" - config = { - "graphite": {"host": "foo", "port": 123, "protocol": "udp", "prefix": "me"} - } - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.call_count == 1 - assert mock_gf.call_args == mock.call(self.hass, "foo", 123, "udp", "me") - assert mock_socket.call_count == 0 - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_config_port(self, mock_gf, mock_socket): - """Test setup with invalid port.""" - config = {"graphite": {"host": "foo", "port": 2003}} - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.called - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - def test_subscribe(self): - """Test the subscription.""" - fake_hass = mock.MagicMock() - gf = graphite.GraphiteFeeder(fake_hass, "foo", 123, "tcp", "ha") - fake_hass.bus.listen_once.has_calls( - [ - mock.call(EVENT_HOMEASSISTANT_START, gf.start_listen), - mock.call(EVENT_HOMEASSISTANT_STOP, gf.shutdown), - ] - ) - assert fake_hass.bus.listen.call_count == 1 - assert fake_hass.bus.listen.call_args == mock.call( - EVENT_STATE_CHANGED, gf.event_listener - ) - - def test_start(self): - """Test the start.""" - with mock.patch.object(self.gf, "start") as mock_start: - self.gf.start_listen("event") - assert mock_start.call_count == 1 - assert mock_start.call_args == mock.call() - - def test_shutdown(self): - """Test the shutdown.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - self.gf.shutdown("event") - assert mock_queue.put.call_count == 1 - assert mock_queue.put.call_args == mock.call(self.gf._quit_object) - - def test_event_listener(self): - """Test the event listener.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - self.gf.event_listener("foo") - assert mock_queue.put.call_count == 1 - assert mock_queue.put.call_args == mock.call("foo") - - @patch("time.time") - def test_report_attributes(self, mock_time): - """Test the reporting with attributes.""" - mock_time.return_value = 12345 - attrs = {"foo": 1, "bar": 2.0, "baz": True, "bat": "NaN"} - - expected = [ - "ha.entity.state 0.000000 12345", - "ha.entity.foo 1.000000 12345", - "ha.entity.bar 2.000000 12345", - "ha.entity.baz 1.000000 12345", - ] - - state = mock.MagicMock(state=0, attributes=attrs) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_report_with_string_state(self, mock_time): - """Test the reporting with strings.""" - mock_time.return_value = 12345 - expected = ["ha.entity.foo 1.000000 12345", "ha.entity.state 1.000000 12345"] - - state = mock.MagicMock(state="above_horizon", attributes={"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_report_with_binary_state(self, mock_time): - """Test the reporting with binary state.""" - mock_time.return_value = 12345 - state = ha.State("domain.entity", STATE_ON, {"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - expected = [ - "ha.entity.foo 1.000000 12345", - "ha.entity.state 1.000000 12345", - ] - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - state.state = STATE_OFF - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - expected = [ - "ha.entity.foo 1.000000 12345", - "ha.entity.state 0.000000 12345", - ] - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_send_to_graphite_errors(self, mock_time): - """Test the sending with errors.""" - mock_time.return_value = 12345 - state = ha.State("domain.entity", STATE_ON, {"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - mock_send.side_effect = socket.error - self.gf._report_attributes("entity", state) - mock_send.side_effect = socket.gaierror - self.gf._report_attributes("entity", state) - - @patch("socket.socket") - def test_send_to_graphite(self, mock_socket): - """Test the sending of data.""" - self.gf._send_to_graphite("foo") - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - sock = mock_socket.return_value - assert sock.connect.call_count == 1 - assert sock.connect.call_args == mock.call(("foo", 123)) - assert sock.sendall.call_count == 1 - assert sock.sendall.call_args == mock.call(b"foo") - assert sock.send.call_count == 1 - assert sock.send.call_args == mock.call(b"\n") - assert sock.close.call_count == 1 - assert sock.close.call_args == mock.call() - - def test_run_stops(self): - """Test the stops.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - mock_queue.get.return_value = self.gf._quit_object - assert self.gf.run() is None - assert mock_queue.get.call_count == 1 - assert mock_queue.get.call_args == mock.call() - assert mock_queue.task_done.call_count == 1 - assert mock_queue.task_done.call_args == mock.call() - - def test_run(self): - """Test the running.""" - runs = [] - event = mock.MagicMock( - event_type=EVENT_STATE_CHANGED, - data={"entity_id": "entity", "new_state": mock.MagicMock()}, - ) - - def fake_get(): - if len(runs) >= 2: - return self.gf._quit_object - if runs: - runs.append(1) - return mock.MagicMock( - event_type="somethingelse", data={"new_event": None} - ) - runs.append(1) - return event - - with mock.patch.object(self.gf, "_queue") as mock_queue, mock.patch.object( - self.gf, "_report_attributes" - ) as mock_r: - mock_queue.get.side_effect = fake_get - self.gf.run() - # Twice for two events, once for the stop - assert mock_queue.task_done.call_count == 3 - assert mock_r.call_count == 1 - assert mock_r.call_args == mock.call("entity", event.data["new_state"]) +async def test_send_to_graphite_errors( + hass, mock_socket, mock_time, caplog, error, log_text +): + """Test the sending with errors.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + mock_socket.return_value.connect.side_effect = error + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert log_text in caplog.text diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index be50f29fe5cb8d..3ba4aaaad81a06 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -13,12 +13,13 @@ ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -76,7 +77,7 @@ async def test_default_state(hass): assert state.attributes.get(ATTR_ENTITY_ID) == ["light.kitchen", "light.bedroom"] assert state.attributes.get(ATTR_BRIGHTNESS) is None assert state.attributes.get(ATTR_HS_COLOR) is None - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None @@ -685,7 +686,7 @@ async def test_color_temp(hass, enable_custom_integrations): entity0.supported_color_modes = {ColorMode.COLOR_TEMP} entity0.color_mode = ColorMode.COLOR_TEMP entity0.brightness = 255 - entity0.color_temp = 2 + entity0.color_temp_kelvin = 2 entity1 = platform.ENTITIES[1] entity1.supported_features = SUPPORT_COLOR_TEMP @@ -710,20 +711,20 @@ async def test_color_temp(hass, enable_custom_integrations): state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 2 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] await hass.services.async_call( "light", "turn_on", - {"entity_id": [entity1.entity_id], ATTR_COLOR_TEMP: 1000}, + {"entity_id": [entity1.entity_id], ATTR_COLOR_TEMP_KELVIN: 1000}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 501 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 501 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] @@ -736,7 +737,7 @@ async def test_color_temp(hass, enable_custom_integrations): await hass.async_block_till_done() state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 1000 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 1000 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] @@ -819,14 +820,14 @@ async def test_min_max_mireds(hass, enable_custom_integrations): entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {ColorMode.COLOR_TEMP} entity0.color_mode = ColorMode.COLOR_TEMP - entity0.color_temp = 2 - entity0.min_mireds = 2 - entity0.max_mireds = 5 + entity0.color_temp_kelvin = 2 + entity0._attr_min_color_temp_kelvin = 2 + entity0._attr_max_color_temp_kelvin = 5 entity1 = platform.ENTITIES[1] entity1.supported_features = SUPPORT_COLOR_TEMP - entity1.min_mireds = 1 - entity1.max_mireds = 1234567890 + entity1._attr_min_color_temp_kelvin = 1 + entity1._attr_max_color_temp_kelvin = 1234567890 assert await async_setup_component( hass, @@ -848,8 +849,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 await hass.services.async_call( "light", @@ -859,8 +860,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): ) await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 await hass.services.async_call( "light", @@ -870,8 +871,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): ) await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 async def test_effect_list(hass): @@ -1448,7 +1449,7 @@ async def test_invalid_service_calls(hass): ATTR_BRIGHTNESS: 150, ATTR_XY_COLOR: (0.5, 0.42), ATTR_RGB_COLOR: (80, 120, 50), - ATTR_COLOR_TEMP: 1234, + ATTR_COLOR_TEMP_KELVIN: 1234, ATTR_EFFECT: "Sunshine", ATTR_TRANSITION: 4, ATTR_FLASH: "long", diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 7a4a41839efc94..0d89bd9a1e0f26 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test number registered attributes to be excluded.""" hass.states.async_set("light.bowl", STATE_ON) diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index 492a486f76d252..c7bffee4fff046 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -32,31 +32,31 @@ def config_fixture(hass): } -@pytest.fixture(name="data_sensor_pair_dump", scope="session") +@pytest.fixture(name="data_sensor_pair_dump", scope="package") def data_sensor_pair_dump_fixture(): """Define data from a successful sensor_pair_dump response.""" return json.loads(load_fixture("sensor_pair_dump_data.json", "guardian")) -@pytest.fixture(name="data_sensor_pair_sensor", scope="session") +@pytest.fixture(name="data_sensor_pair_sensor", scope="package") def data_sensor_pair_sensor_fixture(): """Define data from a successful sensor_pair_sensor response.""" return json.loads(load_fixture("sensor_pair_sensor_data.json", "guardian")) -@pytest.fixture(name="data_sensor_paired_sensor_status", scope="session") +@pytest.fixture(name="data_sensor_paired_sensor_status", scope="package") def data_sensor_paired_sensor_status_fixture(): """Define data from a successful sensor_paired_sensor_status response.""" return json.loads(load_fixture("sensor_paired_sensor_status_data.json", "guardian")) -@pytest.fixture(name="data_system_diagnostics", scope="session") +@pytest.fixture(name="data_system_diagnostics", scope="package") def data_system_diagnostics_fixture(): """Define data from a successful system_diagnostics response.""" return json.loads(load_fixture("system_diagnostics_data.json", "guardian")) -@pytest.fixture(name="data_system_onboard_sensor_status", scope="session") +@pytest.fixture(name="data_system_onboard_sensor_status", scope="package") def data_system_onboard_sensor_status_fixture(): """Define data from a successful system_onboard_sensor_status response.""" return json.loads( @@ -64,19 +64,19 @@ def data_system_onboard_sensor_status_fixture(): ) -@pytest.fixture(name="data_system_ping", scope="session") +@pytest.fixture(name="data_system_ping", scope="package") def data_system_ping_fixture(): """Define data from a successful system_ping response.""" return json.loads(load_fixture("system_ping_data.json", "guardian")) -@pytest.fixture(name="data_valve_status", scope="session") +@pytest.fixture(name="data_valve_status", scope="package") def data_valve_status_fixture(): """Define data from a successful valve_status response.""" return json.loads(load_fixture("valve_status_data.json", "guardian")) -@pytest.fixture(name="data_wifi_status", scope="session") +@pytest.fixture(name="data_wifi_status", scope="package") def data_wifi_status_fixture(): """Define data from a successful wifi_status response.""" return json.loads(load_fixture("wifi_status_data.json", "guardian")) diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index 2269d09b1eba96..ca6a8c7703900b 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -14,12 +14,21 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_guardian assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "guardian", + "title": REDACTED, "data": { + "uid": REDACTED, "ip_address": "192.168.1.100", "port": 7777, - "uid": REDACTED, }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "valve_controller": { diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index a601f98f1c5fd5..c2dab178ad83bb 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -133,6 +133,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 1f915e17e616a0..9eaaf5f97d9913 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -139,6 +139,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_diagnostics( diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 655cc4b23b5aaa..94e989f3c777fd 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -5,7 +5,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.hassio.discovery import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED @@ -14,8 +14,8 @@ from tests.common import MockModule, mock_entity_platform, mock_integration -@pytest.fixture -async def mock_mqtt(hass): +@pytest.fixture(name="mock_mqtt") +async def mock_mqtt_fixture(hass): """Mock the MQTT integration's config flow.""" mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) @@ -78,7 +78,9 @@ async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client, moc "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) @@ -140,7 +142,9 @@ async def test_hassio_discovery_startup_done( "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) @@ -190,6 +194,8 @@ async def test_hassio_discovery_webhook(hass, aioclient_mock, hassio_client, moc "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f0f94661d50805..371398e32c958a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -183,6 +183,19 @@ def mock_all(aioclient_mock, request, os_info): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_setup_api_ping(hass, aioclient_mock): @@ -191,7 +204,7 @@ async def test_setup_api_ping(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -230,7 +243,7 @@ async def test_setup_api_push_api_data(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -246,7 +259,7 @@ async def test_setup_api_push_api_data_server_host(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -258,7 +271,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storag result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -325,7 +338,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, hass_storage result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -339,7 +352,7 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -356,7 +369,7 @@ async def test_setup_hassio_no_additional_data(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -426,14 +439,14 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -448,7 +461,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 13 + assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[-1][2] == { "homeassistant": True, "addons": ["test"], @@ -472,7 +485,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -491,12 +504,12 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -505,7 +518,7 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 async def test_entry_load_and_unload(hass): @@ -758,7 +771,7 @@ async def test_setup_hardware_integration(hass, aioclient_mock, integration): assert result await hass.async_block_till_done() - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py new file mode 100644 index 00000000000000..f420e926b0906a --- /dev/null +++ b/tests/components/hassio/test_repairs.py @@ -0,0 +1,464 @@ +"""Test repairs from supervisor issues.""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .test_init import MOCK_ENVIRON + +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +async def setup_repairs(hass): + """Set up the repairs integration.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest): + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/store", + json={ + "result": "ok", + "data": {"addons": [], "repositories": []}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + + +@pytest.fixture(autouse=True) +async def fixture_supervisor_environ(): + """Mock os environ for supervisor.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +def mock_resolution_info( + aioclient_mock: AiohttpClientMocker, + unsupported: list[str] | None = None, + unhealthy: list[str] | None = None, +): + """Mock resolution/info endpoint with unsupported/unhealthy reasons.""" + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": unsupported or [], + "unhealthy": unhealthy or [], + "suggestions": [], + "issues": [], + "checks": [ + {"enabled": True, "slug": "supervisor_trust"}, + {"enabled": True, "slug": "free_space"}, + ], + }, + }, + ) + + +def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: str): + """Assert repair for unhealthy/unsupported in list.""" + repair_type = "unhealthy" if unhealthy else "unsupported" + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": f"{repair_type}_system_{reason}", + "issue_domain": None, + "learn_more_url": f"https://www.home-assistant.io/more-info/{repair_type}/{reason}", + "severity": "critical" if unhealthy else "warning", + "translation_key": f"{repair_type}_{reason}", + "translation_placeholders": None, + } in issues + + +async def test_unhealthy_repairs( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test repairs added for unhealthy systems.""" + mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") + + +async def test_unsupported_repairs( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test repairs added for unsupported systems.""" + mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason="content_trust" + ) + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + +async def test_unhealthy_repairs_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test unhealthy repairs added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "health_changed", + "data": { + "healthy": False, + "unhealthy_reasons": ["docker"], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "health_changed", + "data": {"healthy": True}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_unsupported_repairs_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test unsupported repairs added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "supported_changed", + "data": { + "supported": False, + "unsupported_reasons": ["os"], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "supported_changed", + "data": {"supported": True}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_reset_repairs_supervisor_restart( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Unsupported/unhealthy repairs reset on supervisor restart.""" + mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + aioclient_mock.clear_requests() + mock_resolution_info(aioclient_mock) + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_reasons_added_and_removed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test an unsupported/unhealthy reasons being added and removed at same time.""" + mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + aioclient_mock.clear_requests() + mock_resolution_info( + aioclient_mock, unsupported=["content_trust"], unhealthy=["setup"] + ) + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason="content_trust" + ) + + +async def test_ignored_unsupported_skipped( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Unsupported reasons which have an identical unhealthy reason are ignored.""" + mock_resolution_info( + aioclient_mock, unsupported=["privileged"], unhealthy=["privileged"] + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="privileged") + + +async def test_new_unsupported_unhealthy_reason( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """New unsupported/unhealthy reasons result in a generic repair until next core update.""" + mock_resolution_info( + aioclient_mock, unsupported=["fake_unsupported"], unhealthy=["fake_unhealthy"] + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": "unhealthy_system_fake_unhealthy", + "issue_domain": None, + "learn_more_url": "https://www.home-assistant.io/more-info/unhealthy/fake_unhealthy", + "severity": "critical", + "translation_key": "unhealthy", + "translation_placeholders": {"reason": "fake_unhealthy"}, + } in msg["result"]["issues"] + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": "unsupported_system_fake_unsupported", + "issue_domain": None, + "learn_more_url": "https://www.home-assistant.io/more-info/unsupported/fake_unsupported", + "severity": "warning", + "translation_key": "unsupported", + "translation_placeholders": {"reason": "fake_unsupported"}, + } in msg["result"]["issues"] diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 16cce09b800a76..e9f0bd631b0679 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -126,6 +126,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index aaa77cde129d9e..02d6b1dbf6bc4a 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -139,6 +139,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 5d11d13166e58b..767f0abaf35ea0 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -61,6 +61,19 @@ def mock_all(aioclient_mock): aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_ws_subscription(hassio_env, hass: HomeAssistant, hass_ws_client): diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index b56f97a8053a1d..120ffd828bc50a 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -31,6 +31,11 @@ CONF_UNIT_SYSTEM_METRIC, ) from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .const import ( API_KEY, @@ -227,10 +232,21 @@ async def test_step_destination_coordinates( @pytest.mark.usefixtures("valid_response") +@pytest.mark.parametrize( + "unit_system, expected_unit_option", + [ + (METRIC_SYSTEM, CONF_UNIT_SYSTEM_METRIC), + (US_CUSTOMARY_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL), + ], +) async def test_step_destination_entity( - hass: HomeAssistant, origin_step_result: data_entry_flow.FlowResult + hass: HomeAssistant, + origin_step_result: data_entry_flow.FlowResult, + unit_system: UnitSystem, + expected_unit_option: str, ) -> None: """Test the origin coordinates step.""" + hass.config.units = unit_system menu_result = await hass.config_entries.flow.async_configure( origin_step_result["flow_id"], {"next_step_id": "destination_entity"} ) @@ -250,6 +266,13 @@ async def test_step_destination_entity( CONF_DESTINATION_ENTITY_ID: "zone.home", CONF_MODE: TRAVEL_MODE_CAR, } + assert entry.options == { + CONF_UNIT_SYSTEM: expected_unit_option, + CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: TRAFFIC_MODE_ENABLED, + CONF_ARRIVAL_TIME: None, + CONF_DEPARTURE_TIME: None, + } async def test_form_invalid_auth(hass: HomeAssistant) -> None: diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 60b1f5fcced624..5cc4802d253ded 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -16,7 +16,6 @@ CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, - CONF_UNIT_SYSTEM, DOMAIN, ICON_BICYCLE, ICON_CAR, @@ -41,6 +40,7 @@ CONF_API_KEY, CONF_MODE, CONF_NAME, + CONF_UNIT_SYSTEM, EVENT_HOMEASSISTANT_START, LENGTH_KILOMETERS, LENGTH_MILES, @@ -179,10 +179,6 @@ async def test_sensor( hass.states.get("sensor.test_distance").attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_distance_unit ) - assert hass.states.get("sensor.test_route").state == ( - "US-29 - K St NW; US-29 - Whitehurst Fwy; " - "I-495 N - Capital Beltway; MD-187 S - Old Georgetown Rd" - ) assert ( hass.states.get("sensor.test_duration_in_traffic").state == expected_duration_in_traffic diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 5441722c9d7a23..981ff3bc08d419 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -587,7 +587,7 @@ def set_state(entity_id, state, **kwargs): return zero, four, states -async def test_fetch_period_api(hass, hass_client, recorder_mock): +async def test_fetch_period_api(recorder_mock, hass, hass_client): """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) client = await hass_client() @@ -596,7 +596,7 @@ async def test_fetch_period_api(hass, hass_client, recorder_mock): async def test_fetch_period_api_with_use_include_order( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history with include order.""" await async_setup_component( @@ -607,7 +607,7 @@ async def test_fetch_period_api_with_use_include_order( assert response.status == HTTPStatus.OK -async def test_fetch_period_api_with_minimal_response(hass, recorder_mock, hass_client): +async def test_fetch_period_api_with_minimal_response(recorder_mock, hass, hass_client): """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) @@ -647,7 +647,7 @@ async def test_fetch_period_api_with_minimal_response(hass, recorder_mock, hass_ ).replace('"', "") -async def test_fetch_period_api_with_no_timestamp(hass, hass_client, recorder_mock): +async def test_fetch_period_api_with_no_timestamp(recorder_mock, hass, hass_client): """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) client = await hass_client() @@ -655,7 +655,7 @@ async def test_fetch_period_api_with_no_timestamp(hass, hass_client, recorder_mo assert response.status == HTTPStatus.OK -async def test_fetch_period_api_with_include_order(hass, hass_client, recorder_mock): +async def test_fetch_period_api_with_include_order(recorder_mock, hass, hass_client): """Test the fetch period view for history.""" await async_setup_component( hass, @@ -676,7 +676,7 @@ async def test_fetch_period_api_with_include_order(hass, hass_client, recorder_m async def test_fetch_period_api_with_entity_glob_include( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -704,7 +704,7 @@ async def test_fetch_period_api_with_entity_glob_include( async def test_fetch_period_api_with_entity_glob_exclude( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -744,7 +744,7 @@ async def test_fetch_period_api_with_entity_glob_exclude( async def test_fetch_period_api_with_entity_glob_include_and_exclude( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -786,7 +786,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( assert response_json[3][0]["entity_id"] == "switch.match" -async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): +async def test_entity_ids_limit_via_api(recorder_mock, hass, hass_client): """Test limiting history to entity_ids.""" await async_setup_component( hass, @@ -811,7 +811,7 @@ async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): async def test_entity_ids_limit_via_api_with_skip_initial_state( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test limiting history to entity_ids with skip_initial_state.""" await async_setup_component( @@ -844,7 +844,7 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( assert response_json[1][0]["entity_id"] == "light.cow" -async def test_statistics_during_period(hass, hass_ws_client, recorder_mock, caplog): +async def test_statistics_during_period(recorder_mock, hass, hass_ws_client, caplog): """Test history/statistics_during_period forwards to recorder.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) @@ -889,7 +889,7 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock, cap ws_mock.assert_awaited_once() -async def test_list_statistic_ids(hass, hass_ws_client, recorder_mock, caplog): +async def test_list_statistic_ids(recorder_mock, hass, hass_ws_client, caplog): """Test history/list_statistic_ids forwards to recorder.""" await async_setup_component(hass, "history", {}) client = await hass_ws_client() @@ -914,7 +914,7 @@ async def test_list_statistic_ids(hass, hass_ws_client, recorder_mock, caplog): ws_mock.assert_called_once() -async def test_history_during_period(hass, hass_ws_client, recorder_mock): +async def test_history_during_period(recorder_mock, hass, hass_ws_client): """Test history_during_period.""" now = dt_util.utcnow() @@ -1047,7 +1047,7 @@ async def test_history_during_period(hass, hass_ws_client, recorder_mock): async def test_history_during_period_impossible_conditions( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period returns when condition cannot be true.""" await async_setup_component(hass, "history", {}) @@ -1109,7 +1109,7 @@ async def test_history_during_period_impossible_conditions( "time_zone", ["UTC", "Europe/Berlin", "America/Chicago", "US/Hawaii"] ) async def test_history_during_period_significant_domain( - time_zone, hass, hass_ws_client, recorder_mock + time_zone, recorder_mock, hass, hass_ws_client ): """Test history_during_period with climate domain.""" hass.config.set_time_zone(time_zone) @@ -1274,7 +1274,7 @@ async def test_history_during_period_significant_domain( async def test_history_during_period_bad_start_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period bad state time.""" await async_setup_component( @@ -1296,7 +1296,7 @@ async def test_history_during_period_bad_start_time( assert response["error"]["code"] == "invalid_start_time" -async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_history_during_period_bad_end_time(recorder_mock, hass, hass_ws_client): """Test history_during_period bad end time.""" now = dt_util.utcnow() @@ -1321,7 +1321,7 @@ async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder async def test_history_during_period_with_use_include_order( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period.""" now = dt_util.utcnow() diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 5de74f71d1ea72..6bae61b5fd8376 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -136,7 +136,7 @@ def init_recorder(self): self.hass.start() -async def test_invalid_date_for_start(hass, recorder_mock): +async def test_invalid_date_for_start(recorder_mock, hass): """Verify with an invalid date for start.""" await async_setup_component( hass, @@ -161,7 +161,7 @@ async def test_invalid_date_for_start(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_date_for_end(hass, recorder_mock): +async def test_invalid_date_for_end(recorder_mock, hass): """Verify with an invalid date for end.""" await async_setup_component( hass, @@ -186,7 +186,7 @@ async def test_invalid_date_for_end(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_entity_in_template(hass, recorder_mock): +async def test_invalid_entity_in_template(recorder_mock, hass): """Verify with an invalid entity in the template.""" await async_setup_component( hass, @@ -211,7 +211,7 @@ async def test_invalid_entity_in_template(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): +async def test_invalid_entity_returning_none_in_template(recorder_mock, hass): """Verify with an invalid entity returning none in the template.""" await async_setup_component( hass, @@ -236,7 +236,7 @@ async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_reload(hass, recorder_mock): +async def test_reload(recorder_mock, hass): """Verify we can reload history_stats sensors.""" hass.state = ha.CoreState.not_running hass.states.async_set("binary_sensor.test_id", "on") @@ -279,7 +279,7 @@ async def test_reload(hass, recorder_mock): assert hass.states.get("sensor.second_test") -async def test_measure_multiple(hass, recorder_mock): +async def test_measure_multiple(recorder_mock, hass): """Test the history statistics sensor measure for multiple .""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -361,7 +361,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.sensor4").state == "50.0" -async def test_measure(hass, recorder_mock): +async def test_measure(recorder_mock, hass): """Test the history statistics sensor measure.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -440,7 +440,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.sensor4").state == "83.3" -async def test_async_on_entire_period(hass, recorder_mock): +async def test_async_on_entire_period(recorder_mock, hass): """Test the history statistics sensor measuring as on the entire period.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -520,7 +520,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.on_sensor4").state == "100.0" -async def test_async_off_entire_period(hass, recorder_mock): +async def test_async_off_entire_period(recorder_mock, hass): """Test the history statistics sensor measuring as off the entire period.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -602,8 +602,8 @@ def _fake_states(*args, **kwargs): async def test_async_start_from_history_and_switch_to_watching_state_changes_single( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes.""" hass.config.set_time_zone("UTC") @@ -702,8 +702,8 @@ def _fake_states(*args, **kwargs): async def test_async_start_from_history_and_switch_to_watching_state_changes_single_expanding_window( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes with an expanding end time.""" hass.config.set_time_zone("UTC") @@ -801,8 +801,8 @@ def _fake_states(*args, **kwargs): async def test_async_start_from_history_and_switch_to_watching_state_changes_multiple( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes.""" hass.config.set_time_zone("UTC") @@ -938,7 +938,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.sensor4").state == "87.5" -async def test_does_not_work_into_the_future(hass, recorder_mock): +async def test_does_not_work_into_the_future(recorder_mock, hass): """Test history cannot tell the future. Verifies we do not regress https://github.com/home-assistant/core/pull/20589 @@ -1078,7 +1078,7 @@ def _fake_off_states(*args, **kwargs): assert hass.states.get("sensor.sensor1").state == "0.0" -async def test_reload_before_start_event(hass, recorder_mock): +async def test_reload_before_start_event(recorder_mock, hass): """Verify we can reload history_stats sensors before the start event.""" hass.state = ha.CoreState.not_running hass.states.async_set("binary_sensor.test_id", "on") @@ -1119,7 +1119,7 @@ async def test_reload_before_start_event(hass, recorder_mock): assert hass.states.get("sensor.second_test") -async def test_measure_sliding_window(hass, recorder_mock): +async def test_measure_sliding_window(recorder_mock, hass): """Test the history statistics sensor with a moving end and a moving start.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -1212,7 +1212,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.sensor4").state == "41.7" -async def test_measure_from_end_going_backwards(hass, recorder_mock): +async def test_measure_from_end_going_backwards(recorder_mock, hass): """Test the history statistics sensor with a moving end and a duration to find the start.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -1304,7 +1304,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.sensor4").state == "83.3" -async def test_measure_cet(hass, recorder_mock): +async def test_measure_cet(recorder_mock, hass): """Test the history statistics sensor measure with a non-UTC timezone.""" hass.config.set_time_zone("Europe/Berlin") start_time = dt_util.utcnow() - timedelta(minutes=60) @@ -1385,10 +1385,12 @@ def _fake_states(*args, **kwargs): @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) -async def test_end_time_with_microseconds_zeroed(time_zone, hass, recorder_mock): +async def test_end_time_with_microseconds_zeroed(time_zone, recorder_mock, hass): """Test the history statistics sensor that has the end time microseconds zeroed out.""" hass.config.set_time_zone(time_zone) - start_of_today = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_of_today = dt_util.now().replace( + day=9, month=7, year=1986, hour=0, minute=0, second=0, microsecond=0 + ) start_time = start_of_today + timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) t1 = t0 + timedelta(minutes=10) @@ -1498,7 +1500,7 @@ def _fake_states(*args, **kwargs): assert hass.states.get("sensor.heatpump_compressor_today").state == "16.0" -async def test_device_classes(hass, recorder_mock): +async def test_device_classes(recorder_mock, hass): """Test the device classes.""" await async_setup_component( hass, diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5e2acbcd9dbd35..7b79e0f9b6b6f2 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -3,17 +3,50 @@ import os from unittest.mock import patch -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.accessories import HomeDriver, HomeIIDManager +from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage from tests.common import async_capture_events, mock_device_registry, mock_registry @pytest.fixture -def hk_driver(loop): +def iid_storage(hass): + """Mock the iid storage.""" + with patch.object(AccessoryIIDStorage, "_async_schedule_save"): + yield AccessoryIIDStorage(hass, "") + + +@pytest.fixture() +def run_driver(hass, loop, iid_storage): + """Return a custom AccessoryDriver instance for HomeKit accessory init. + + This mock does not mock async_stop, so the driver will not be stopped + """ + with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( + "pyhap.accessory_driver.AccessoryEncoder" + ), patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.AccessoryDriver.publish" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) + + +@pytest.fixture +def hk_driver(hass, loop, iid_storage): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -24,11 +57,20 @@ def hk_driver(loop): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture -def mock_hap(loop, mock_zeroconf): +def mock_hap(hass, loop, iid_storage, mock_zeroconf): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -43,7 +85,16 @@ def mock_hap(loop, mock_zeroconf): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 6d7de6eb696a79..2a0f3f2f718797 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -10,6 +10,7 @@ HomeAccessory, HomeBridge, HomeDriver, + HomeIIDManager, ) from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, @@ -107,7 +108,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 4, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -140,7 +141,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 5, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -191,7 +192,7 @@ async def test_home_accessory(hass, hk_driver): entity_id = "test_model.demo" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 6, None) serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_MODEL).value == "Test Model" @@ -317,7 +318,7 @@ async def test_battery_service(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 3, None) assert acc._char_battery.value == 0 assert acc._char_low_battery.value == 0 assert acc._char_charging.value == 2 @@ -405,7 +406,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog): hk_driver, "Battery Service", entity_id, - 2, + 3, {CONF_LINKED_BATTERY_SENSOR: linked_battery, CONF_LOW_BATTERY_THRESHOLD: 50}, ) with patch( @@ -700,16 +701,17 @@ def test_home_bridge(hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == BRIDGE_SERIAL_NUMBER + +def test_home_bridge_setup_message(hk_driver): + """Test HomeBridge setup message.""" bridge = HomeBridge("hass", hk_driver, "test_name") assert bridge.display_name == "test_name" assert len(bridge.services) == 2 - serv = bridge.services[0] # SERV_ACCESSORY_INFO - # setup_message bridge.setup_message() -def test_home_driver(): +def test_home_driver(iid_storage): """Test HomeDriver class.""" ip_address = "127.0.0.1" port = 51826 @@ -722,6 +724,7 @@ def test_home_driver(): "entry_id", "name", "title", + iid_manager=HomeIIDManager(iid_storage), address=ip_address, port=port, persist_file=path, @@ -749,3 +752,22 @@ def test_home_driver(): mock_unpair.assert_called_with("client_uuid") mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0") + + +async def test_iid_collision_raises(hass, hk_driver): + """Test iid collision raises. + + If we try to allocate the same IID to the an accessory twice, we should + raise an exception. + """ + + entity_id = "light.accessory" + entity_id2 = "light.accessory2" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id2, STATE_OFF) + + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + + with pytest.raises(RuntimeError): + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id2, 2, {}) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 1b2a0b4e211c4d..144a97853c58aa 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -387,8 +387,9 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): } +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices( - mock_hap, + port_mock, hass, demo_cleanup, device_reg, @@ -473,9 +474,13 @@ async def test_options_flow_devices( }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices_preserved_when_advanced_off( - mock_hap, hass, mock_get_source_ip, mock_async_zeroconf + port_mock, hass, mock_get_source_ip, mock_async_zeroconf ): """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( @@ -542,6 +547,8 @@ async def test_options_flow_devices_preserved_when_advanced_off( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_non_existant_entity( @@ -600,6 +607,8 @@ async def test_options_flow_include_mode_with_non_existant_entity( "include_entities": ["climate.new", "climate.front_gate"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_non_existant_entity( @@ -659,6 +668,8 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): @@ -704,6 +715,7 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): "include_entities": ["climate.new"], }, } + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): @@ -809,6 +821,8 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): }, "entity_config": {"camera.native_h264": {"video_codec": "copy"}}, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): @@ -941,6 +955,8 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): @@ -1073,6 +1089,8 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): @@ -1112,6 +1130,7 @@ async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): user_input={}, ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1211,6 +1230,8 @@ async def test_options_flow_include_mode_basic_accessory( "include_entities": ["media_player.tv"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip): @@ -1317,6 +1338,8 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou }, } assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) def _get_schema_default(schema, key_name): @@ -1423,6 +1446,8 @@ async def test_options_flow_exclude_mode_skips_category_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1501,6 +1526,8 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1583,3 +1610,5 @@ async def test_options_flow_include_mode_allows_hidden_entities( ], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index e3a85b85972f97..1f6f7c584f3ee6 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -1,7 +1,11 @@ """Test homekit diagnostics.""" from unittest.mock import ANY, patch -from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.const import ( + CONF_HOMEKIT_MODE, + DOMAIN, + HOMEKIT_MODE_ACCESSORY, +) from homeassistant.const import CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED from .util import async_init_integration @@ -28,7 +32,7 @@ async def test_config_entry_not_running( async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zeroconf): - """Test generating diagnostics for a config entry.""" + """Test generating diagnostics for a bridge config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) @@ -38,6 +42,7 @@ async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zer await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == { + "bridge": {}, "accessories": [ { "aid": 1, @@ -117,3 +122,145 @@ async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zer ), patch("homeassistant.components.homekit.async_port_is_available"): assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_config_entry_accessory( + hass, hass_client, hk_driver, mock_async_zeroconf +): + """Test generating diagnostics for an accessory config entry.""" + hass.states.async_set("light.demo", "on") + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "mock_name", + CONF_PORT: 12345, + CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["light.demo"], + }, + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert diag == { + "accessories": [ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"format": "bool", "iid": 2, "perms": ["pw"], "type": "14"}, + { + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "20", + "value": "Home Assistant " "Light", + }, + { + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "21", + "value": "Light", + }, + { + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "23", + "value": "demo", + }, + { + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "30", + "value": "light.demo", + }, + { + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "52", + "value": ANY, + }, + ], + "iid": 1, + "type": "3E", + }, + { + "characteristics": [ + { + "format": "string", + "iid": 9, + "perms": ["pr", "ev"], + "type": "37", + "value": "01.01.00", + } + ], + "iid": 8, + "type": "A2", + }, + { + "characteristics": [ + { + "format": "bool", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "25", + "value": True, + } + ], + "iid": 10, + "type": "43", + }, + ], + } + ], + "accessory": { + "aid": 1, + "category": 5, + "config": {}, + "entity_id": "light.demo", + "entity_state": { + "attributes": {}, + "context": {"id": ANY, "parent_id": None, "user_id": None}, + "entity_id": "light.demo", + "last_changed": ANY, + "last_updated": ANY, + "state": "on", + }, + "name": "demo", + }, + "client_properties": {}, + "config-entry": { + "data": {"name": "mock_name", "port": 12345}, + "options": { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["light.demo"], + }, + "mode": "accessory", + }, + "title": "Mock Title", + "version": 1, + }, + "config_version": 2, + "pairing_id": ANY, + "status": 1, + } + with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( + "homeassistant.components.homekit.HomeKit.async_stop" + ), patch("homeassistant.components.homekit.async_port_is_available"): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index dbb63ba690a60d..21dc94a4b54371 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import os from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from pyhap.accessory import Accessory @@ -60,7 +59,6 @@ convert_filter, ) from homeassistant.setup import async_setup_component -from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration @@ -122,6 +120,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): def _mock_homekit_bridge(hass, entry): homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = MagicMock() + homekit.iid_storage = MagicMock() return homekit @@ -177,6 +176,49 @@ async def test_setup_min(hass, mock_async_zeroconf): assert mock_homekit().async_start.called is True +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_removing_entry(port_mock, hass, mock_async_zeroconf): + """Test removing a config entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + "1.2.3.4", + ANY, + ANY, + {}, + HOMEKIT_MODE_BRIDGE, + None, + entry.entry_id, + entry.title, + devices=[], + ) + + # Test auto start enabled + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert mock_homekit().async_start.called is True + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): """Test setup of bridge and driver.""" entry = MockConfigEntry( @@ -203,6 +245,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): zeroconf_mock = MagicMock() uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) @@ -219,6 +262,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=zeroconf_mock, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) assert homekit.driver.safe_mode is False @@ -247,6 +291,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, mock_async_zeroconf, uuid) mock_driver.assert_called_with( hass, @@ -261,6 +306,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=mock_async_zeroconf, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) @@ -289,6 +335,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid) mock_driver.assert_called_with( hass, @@ -303,10 +350,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) -async def test_homekit_add_accessory(hass, mock_async_zeroconf): +async def test_homekit_add_accessory(hass, mock_async_zeroconf, mock_hap): """Add accessory if config exists and get_acc returns an accessory.""" entry = MockConfigEntry( @@ -340,10 +388,12 @@ async def test_homekit_add_accessory(hass, mock_async_zeroconf): mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {}) assert homekit.bridge.add_accessory.called + await homekit.async_stop() + @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) async def test_homekit_warn_add_accessory_bridge( - hass, acc_category, mock_async_zeroconf, caplog + hass, acc_category, mock_async_zeroconf, mock_hap, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" @@ -367,6 +417,7 @@ async def test_homekit_warn_add_accessory_bridge( homekit.add_bridge_accessory(state) mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {}) assert not homekit.bridge.add_accessory.called + await homekit.async_stop() assert "accessory mode" in caplog.text @@ -385,7 +436,6 @@ async def test_homekit_remove_accessory(hass, mock_async_zeroconf): acc = await homekit.async_remove_bridge_accessory(6) assert acc is acc_mock - assert acc_mock.stop.called assert len(homekit.bridge.accessories) == 0 @@ -722,7 +772,7 @@ async def test_homekit_stop(hass): assert homekit.driver.async_stop.called is True -async def test_homekit_reset_accessories(hass, mock_async_zeroconf): +async def test_homekit_reset_accessories(hass, mock_async_zeroconf, mock_hap): """Test resetting HomeKit accessories.""" entry = MockConfigEntry( @@ -736,20 +786,15 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + ), patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" - ) as mock_run, patch.object( + ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() @@ -761,10 +806,9 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 assert mock_add_accessory.called - assert mock_run.called homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf): @@ -1028,7 +1072,7 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_async_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): +async def test_homekit_reset_single_accessory(hass, mock_hap, mock_async_zeroconf): """Test resetting HomeKit single accessory.""" entry = MockConfigEntry( @@ -1046,13 +1090,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" ) as mock_run: await async_init_entry(hass, entry) - homekit.status = STATUS_RUNNING - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - - homekit.driver.accessory = acc_mock homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( @@ -1065,6 +1103,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): assert mock_run.called assert hk_driver_config_changed.call_count == 1 homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_reset_single_accessory_unsupported(hass, mock_async_zeroconf): @@ -1494,12 +1533,6 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf await hass.async_block_till_done() -def _write_data(path: str, data: dict) -> None: - """Write the data.""" - os.makedirs(os.path.dirname(path), exist_ok=True) - json_util.save_json(path, data) - - async def test_homekit_ignored_missing_devices( hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): diff --git a/tests/components/homekit/test_iidmanager.py b/tests/components/homekit/test_iidmanager.py new file mode 100644 index 00000000000000..a791c30a3416e0 --- /dev/null +++ b/tests/components/homekit/test_iidmanager.py @@ -0,0 +1,101 @@ +"""Tests for the HomeKit IID manager.""" + + +from uuid import UUID + +from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.iidmanager import ( + AccessoryIIDStorage, + get_iid_storage_filename_for_entry_id, +) +from homeassistant.util.uuid import random_uuid_hex + +from tests.common import MockConfigEntry + + +async def test_iid_generation_and_restore(hass, iid_storage, hass_storage): + """Test generating iids and restoring them from storage.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + + random_service_uuid = UUID(random_uuid_hex()) + random_characteristic_uuid = UUID(random_uuid_hex()) + + iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid1 == iid2 + + service_only_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + service_only_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + assert service_only_iid1 == service_only_iid2 + assert service_only_iid1 != iid1 + + service_only_iid_with_unique_id1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + service_only_iid_with_unique_id2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + assert service_only_iid_with_unique_id1 == service_only_iid_with_unique_id2 + assert service_only_iid_with_unique_id1 != service_only_iid1 + + unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + assert unique_char_iid1 == unique_char_iid2 + assert unique_char_iid1 != iid1 + + unique_service_unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert unique_service_unique_char_iid1 == unique_service_unique_char_iid2 + assert unique_service_unique_char_iid1 != iid1 + + unique_service_unique_char_new_aid_iid1 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_new_aid_iid2 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert ( + unique_service_unique_char_new_aid_iid1 + == unique_service_unique_char_new_aid_iid2 + ) + assert unique_service_unique_char_new_aid_iid1 != iid1 + assert unique_service_unique_char_new_aid_iid1 != unique_service_unique_char_iid1 + + await iid_storage.async_save() + + iid_storage2 = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage2.async_initialize() + iid3 = iid_storage2.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid3 == iid1 + + +async def test_iid_storage_filename(hass, iid_storage, hass_storage): + """Test iid storage uses the expected filename.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + assert iid_storage.store.path.endswith( + get_iid_storage_filename_for_entry_id(entry.entry_id) + ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index f6855ca3cbbbc9..80a4f3c4e88982 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from uuid import UUID -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg @@ -78,21 +77,6 @@ async def _async_stop_stream(hass, acc, session_info): await hass.async_block_till_done() -@pytest.fixture() -def run_driver(hass): - """Return a custom AccessoryDriver instance for HomeKit accessory init.""" - with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( - "pyhap.accessory_driver.AccessoryEncoder" - ), patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" - ): - yield AccessoryDriver( - pincode=b"123-45-678", address="127.0.0.1", loop=hass.loop - ) - - def _mock_reader(): """Mock ffmpeg reader.""" diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index bc512a4b162b84..5d261886248c49 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -573,7 +573,7 @@ async def test_windowcovering_basic_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None @@ -611,7 +611,7 @@ async def test_windowcovering_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index fbcf6a004215c8..9b5f8286d8b51d 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -561,7 +561,7 @@ async def test_fan_restore(hass, hk_driver, events): assert acc.char_speed is None assert acc.char_swing is None - acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None) + acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 3, None) assert acc.category == 3 assert acc.char_active is not None assert acc.char_direction is not None diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 64e45aa937db7f..3dcf2a7698c8e9 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -18,7 +18,7 @@ ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, @@ -250,7 +250,7 @@ async def test_light_color_temperature(hass, hk_driver, events): hass.states.async_set( entity_id, STATE_ON, - {ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], ATTR_COLOR_TEMP: 190}, + {ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], ATTR_COLOR_TEMP_KELVIN: 5263}, ) await hass.async_block_till_done() acc = Light(hass, hk_driver, "Light", entity_id, 1, None) @@ -282,7 +282,7 @@ async def test_light_color_temperature(hass, hk_driver, events): await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "color temperature at 250" @@ -302,7 +302,7 @@ async def test_light_color_temperature_and_rgb_color( STATE_ON, { ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, - ATTR_COLOR_TEMP: 190, + ATTR_COLOR_TEMP_KELVIN: 5263, ATTR_HS_COLOR: (260, 90), }, ) @@ -316,7 +316,7 @@ async def test_light_color_temperature_and_rgb_color( assert hasattr(acc, "char_color_temp") - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -324,7 +324,7 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_hue.value == 27 assert acc.char_saturation.value == 27 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -373,7 +373,7 @@ async def test_light_color_temperature_and_rgb_color( assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert ( @@ -446,7 +446,7 @@ async def test_light_color_temperature_and_rgb_color( ) await _wait_for_light_coalesce(hass) assert call_turn_on[3] - assert call_turn_on[3].data[ATTR_COLOR_TEMP] == 320 + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 assert events[-1].data[ATTR_VALUE] == "color temperature at 320" # Generate a conflict by setting color temp then saturation @@ -991,7 +991,7 @@ async def test_light_rgb_with_white_switch_to_temp( await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id - assert call_turn_on[-1].data[ATTR_COLOR_TEMP] == 500 + assert call_turn_on[-1].data[ATTR_COLOR_TEMP_KELVIN] == 2000 assert len(events) == 2 assert events[-1].data[ATTR_VALUE] == "color temperature at 500" assert acc.char_brightness.value == 100 @@ -1335,7 +1335,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: (4461)}) await hass.async_block_till_done() assert acc.char_color_temp.value == 224 @@ -1364,7 +1364,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert ( diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index dbdc2b0ba55015..30b9bc77f5daa9 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -442,7 +442,7 @@ async def test_tv_restore(hass, hk_driver, events): assert not hasattr(acc, "char_input_source") acc = TelevisionMediaPlayer( - hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None + hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 3, None ) assert acc.category == 31 assert acc.chars_tv == [CHAR_REMOTE_KEY] diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 64f1d82d1239bb..920bf6d8a31667 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -282,13 +282,16 @@ async def test_supported_states(hass, hk_driver, events): }, ] + aid = 1 + for test_config in test_configs: attrs = {"supported_features": test_config.get("features")} hass.states.async_set(entity_id, None, attributes=attrs) await hass.async_block_till_done() - acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) + aid += 1 + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, aid, config) await acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index b916d447d12835..4997a35910d8aa 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -423,12 +423,14 @@ async def test_motion_uses_bool(hass, hk_driver): async def test_binary_device_classes(hass, hk_driver): """Test if services and characteristics are assigned correctly.""" entity_id = "binary_sensor.demo" + aid = 1 for device_class, (service, char, _) in BINARY_SENSOR_SERVICE_MAP.items(): hass.states.async_set(entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: device_class}) await hass.async_block_till_done() - acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, 2, None) + aid += 1 + acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, aid, None) assert acc.get_service(service).display_name == service assert acc.char_detected.display_name == char @@ -460,7 +462,7 @@ async def test_sensor_restore(hass, hk_driver, events): acc = get_accessory(hass, hk_driver, hass.states.get("sensor.temperature"), 2, {}) assert acc.category == 10 - acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 2, {}) + acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 3, {}) assert acc.category == 10 diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index cc80201ae333a0..0d6f8f0d586ddf 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -150,23 +150,23 @@ async def test_valve_set_state(hass, hk_driver, events): assert acc.category == 29 # Faucet assert acc.char_valve_type.value == 3 # Water faucet - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SHOWER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER}) await acc.run() await hass.async_block_till_done() assert acc.category == 30 # Shower assert acc.char_valve_type.value == 2 # Shower head - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SPRINKLER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER}) await acc.run() await hass.async_block_till_done() assert acc.category == 28 # Sprinkler assert acc.char_valve_type.value == 1 # Irrigation - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_VALVE}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) await acc.run() await hass.async_block_till_done() - assert acc.aid == 2 + assert acc.aid == 5 assert acc.category == 29 # Faucet assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a964568cc6045c..33b45e54081777 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1045,7 +1045,7 @@ async def test_thermostat_restore(hass, hk_driver, events): "off", } - acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None) + acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { @@ -1859,7 +1859,7 @@ async def test_water_heater_restore(hass, hk_driver, events): } acc = WaterHeater( - hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 2, None + hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None ) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 07cc2b5cae7404..b30ba6236a96bd 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -9,7 +9,12 @@ from typing import Any, Final from unittest import mock -from aiohomekit.model import Accessories, AccessoriesState, Accessory +from aiohomekit.model import ( + Accessories, + AccessoriesState, + Accessory, + mixin as model_mixin, +) from aiohomekit.testing import FakeController, FakePairing from aiohomekit.zeroconf import HomeKitService @@ -19,7 +24,6 @@ DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, IDENTIFIER_ACCESSORY_ID, - IDENTIFIER_SERIAL_NUMBER, ) from homeassistant.components.homekit_controller.utils import async_get_controller from homeassistant.config_entries import ConfigEntry @@ -320,7 +324,6 @@ async def _do_assertions(expected: DeviceTestInfo) -> dr.DeviceEntry: device = device_registry.async_get_device( { - (IDENTIFIER_SERIAL_NUMBER, expected.serial_number), (IDENTIFIER_ACCESSORY_ID, expected.unique_id), } ) @@ -336,21 +339,15 @@ async def _do_assertions(expected: DeviceTestInfo) -> dr.DeviceEntry: # We might have matched the device by one identifier only # Lets check that the other one is correct. Otherwise the test might silently be wrong. - serial_number_set = False accessory_id_set = False for key, value in device.identifiers: - if key == IDENTIFIER_SERIAL_NUMBER: - assert value == expected.serial_number - serial_number_set = True - - elif key == IDENTIFIER_ACCESSORY_ID: + if key == IDENTIFIER_ACCESSORY_ID: assert value == expected.unique_id accessory_id_set = True # If unique_id or serial is provided it MUST actually appear in the device registry entry. assert (not expected.unique_id) ^ accessory_id_set - assert (not expected.serial_number) ^ serial_number_set for entity_info in expected.entities: entity = entity_registry.async_get(entity_info.entity_id) @@ -410,3 +407,8 @@ async def remove_device(ws_client, device_id, config_entry_id): ) response = await ws_client.receive_json() return response["success"] + + +def get_next_aid(): + """Get next aid.""" + return model_mixin.id_counter + 1 diff --git a/tests/components/homekit_controller/fixtures/netatmo_home_coach.json b/tests/components/homekit_controller/fixtures/netatmo_home_coach.json new file mode 100644 index 00000000000000..b17c1bc542cd97 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/netatmo_home_coach.json @@ -0,0 +1,249 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Healthy Home Coach", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Netatmo", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "Healthy Home Coach", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "AAAAAAAAAAAAA", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pr"], + "format": "string", + "value": "59", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 7, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 25, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 26, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 27, + "type": "EA22EA53-6227-55EA-AC24-73ACF3EEA0E8", + "characteristics": [ + { + "type": "4D05AE82-5A22-5BD6-A730-B7F8B4F3218D", + "iid": 28, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "00F44C18-042E-5C4E-9A4C-561D44DCD804", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "g262d1a", + "maxLen": 64 + } + ] + }, + { + "iid": 24, + "type": "0000008D-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000095-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Air Quality", + "minValue": 0, + "maxValue": 5 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr"], + "format": "string", + "value": "Air quality sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 10, + "type": "00000097-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000092-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Carbon Dioxide Detected", + "minValue": 0, + "maxValue": 1 + }, + { + "type": "00000093-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr", "ev"], + "format": "float", + "value": 804, + "description": "Carbon Dioxide Level", + "minValue": 0, + "maxValue": 10000 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 13, + "perms": ["pr"], + "format": "string", + "value": "Carbon Dioxide sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 14, + "type": "00000082-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 15, + "perms": ["pr", "ev"], + "format": "float", + "value": 59, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 16, + "perms": ["pr"], + "format": "string", + "value": "Humidity sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 17, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.9, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 50, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr"], + "format": "string", + "value": "Temperature sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 20, + "type": "6237CEFC-9F4D-54B2-8033-2EDA0053B811", + "characteristics": [ + { + "type": "B3BBFABC-D78C-5B8D-948C-5DAC1EE2CDE5", + "iid": 21, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 200, + "minStep": 1 + }, + { + "type": "627EA399-29D9-5DC8-9A02-08AE928F73D8", + "iid": 22, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 5, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "Noise sensor", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py index 644abb8a3a6704..f2e209a9fdb46c 100644 --- a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py +++ b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py @@ -39,7 +39,7 @@ async def test_eufycam_setup(hass): EntityTestInfo( entity_id="camera.eufycam2_0000", friendly_name="eufyCam2-0000", - unique_id="homekit-A0000A000000000D-aid:4", + unique_id="00:00:00:00:00:00_4", state="idle", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index 75423f3373e1ca..7df51316ceb014 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -37,7 +37,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "alarm_control_panel.aqara_hub_1563_security_system", friendly_name="Aqara Hub-1563 Security System", - unique_id="homekit-0000000123456789-66304", + unique_id="00:00:00:00:00:00_1_66304", supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT | AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY, @@ -46,7 +46,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "light.aqara_hub_1563_lightbulb_1563", friendly_name="Aqara Hub-1563 Lightbulb-1563", - unique_id="homekit-0000000123456789-65792", + unique_id="00:00:00:00:00:00_1_65792", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="off", @@ -54,7 +54,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "number.aqara_hub_1563_volume", friendly_name="Aqara Hub-1563 Volume", - unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65541", + unique_id="00:00:00:00:00:00_1_65536_65541", capabilities={ "max": 100, "min": 0, @@ -67,7 +67,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "switch.aqara_hub_1563_pairing_mode", friendly_name="Aqara Hub-1563 Pairing Mode", - unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65538", + unique_id="00:00:00:00:00:00_1_65536_65538", entity_category=EntityCategory.CONFIG, state="off", ), @@ -96,7 +96,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "alarm_control_panel.aqara_hub_e1_00a0_security_system", friendly_name="Aqara-Hub-E1-00A0 Security System", - unique_id="homekit-00aa00000a0-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT | AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY, @@ -105,7 +105,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "number.aqara_hub_e1_00a0_volume", friendly_name="Aqara-Hub-E1-00A0 Volume", - unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114116", + unique_id="00:00:00:00:00:00_1_17_1114116", capabilities={ "max": 100, "min": 0, @@ -118,7 +118,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "switch.aqara_hub_e1_00a0_pairing_mode", friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", - unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114117", + unique_id="00:00:00:00:00:00_1_17_1114117", entity_category=EntityCategory.CONFIG, state="off", ), diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py index 793fb49af5b96d..6472d9939748ce 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py @@ -42,7 +42,7 @@ async def test_aqara_switch_setup(hass): EntityTestInfo( entity_id="sensor.programmable_switch_battery_sensor", friendly_name="Programmable Switch Battery Sensor", - unique_id="homekit-111a1111a1a111-5", + unique_id="00:00:00:00:00:00_1_5", capabilities={"state_class": SensorStateClass.MEASUREMENT}, entity_category=EntityCategory.DIAGNOSTIC, unit_of_measurement=PERCENTAGE, diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py index 1b2b4bda3d653a..26c0c87e3b317e 100644 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -33,19 +33,19 @@ async def test_arlo_baby_setup(hass): entities=[ EntityTestInfo( entity_id="camera.arlobabya0", - unique_id="homekit-00A0000000000-aid:1", + unique_id="00:00:00:00:00:00_1", friendly_name="ArloBabyA0", state="idle", ), EntityTestInfo( entity_id="binary_sensor.arlobabya0_motion", - unique_id="homekit-00A0000000000-500", + unique_id="00:00:00:00:00:00_1_500", friendly_name="ArloBabyA0 Motion", state="off", ), EntityTestInfo( entity_id="sensor.arlobabya0_battery", - unique_id="homekit-00A0000000000-700", + unique_id="00:00:00:00:00:00_1_700", friendly_name="ArloBabyA0 Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -54,7 +54,7 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_humidity", - unique_id="homekit-00A0000000000-900", + unique_id="00:00:00:00:00:00_1_900", friendly_name="ArloBabyA0 Humidity", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, @@ -62,7 +62,7 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_temperature", - unique_id="homekit-00A0000000000-1000", + unique_id="00:00:00:00:00:00_1_1000", friendly_name="ArloBabyA0 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, @@ -70,14 +70,14 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_air_quality", - unique_id="homekit-00A0000000000-aid:1-sid:800-cid:802", + unique_id="00:00:00:00:00:00_1_800_802", capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="ArloBabyA0 Air Quality", state="1", ), EntityTestInfo( entity_id="light.arlobabya0_nightlight", - unique_id="homekit-00A0000000000-1100", + unique_id="00:00:00:00:00:00_1_1100", friendly_name="ArloBabyA0 Nightlight", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 9e233ebdc10af1..096ed39a3368cf 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -37,7 +37,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_current", friendly_name="InWall Outlet-0394DE Current", - unique_id="homekit-1020301376-aid:1-sid:13-cid:18", + unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state="0.03", @@ -45,7 +45,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_power", friendly_name="InWall Outlet-0394DE Power", - unique_id="homekit-1020301376-aid:1-sid:13-cid:19", + unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=POWER_WATT, state="0.8", @@ -53,7 +53,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", - unique_id="homekit-1020301376-aid:1-sid:13-cid:20", + unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="379.69299", @@ -61,13 +61,13 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="switch.inwall_outlet_0394de_outlet_a", friendly_name="InWall Outlet-0394DE Outlet A", - unique_id="homekit-1020301376-13", + unique_id="00:00:00:00:00:00_1_13", state="on", ), EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_current_2", friendly_name="InWall Outlet-0394DE Current", - unique_id="homekit-1020301376-aid:1-sid:25-cid:30", + unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state="0.05", @@ -75,7 +75,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_power_2", friendly_name="InWall Outlet-0394DE Power", - unique_id="homekit-1020301376-aid:1-sid:25-cid:31", + unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=POWER_WATT, state="0.8", @@ -83,7 +83,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", - unique_id="homekit-1020301376-aid:1-sid:25-cid:32", + unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="175.85001", @@ -91,7 +91,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="switch.inwall_outlet_0394de_outlet_b", friendly_name="InWall Outlet-0394DE Outlet B", - unique_id="homekit-1020301376-25", + unique_id="00:00:00:00:00:00_1_25", state="on", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 69a7d4f809c3e5..299b8d24a9bc2c 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -60,7 +60,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.kitchen", friendly_name="Kitchen", - unique_id="homekit-AB1C-56", + unique_id="00:00:00:00:00:00_2_56", state="off", ), ], @@ -78,7 +78,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.porch", friendly_name="Porch", - unique_id="homekit-AB2C-56", + unique_id="00:00:00:00:00:00_3_56", state="off", ), ], @@ -96,7 +96,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.basement", friendly_name="Basement", - unique_id="homekit-AB3C-56", + unique_id="00:00:00:00:00:00_4_56", state="off", ), ], @@ -106,7 +106,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="climate.homew", friendly_name="HomeW", - unique_id="homekit-123456789012-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE @@ -124,7 +124,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="sensor.homew_current_temperature", friendly_name="HomeW Current Temperature", - unique_id="homekit-123456789012-aid:1-sid:16-cid:19", + unique_id="00:00:00:00:00:00_1_16_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, state="21.8", @@ -132,7 +132,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="select.homew_current_mode", friendly_name="HomeW Current Mode", - unique_id="homekit-123456789012-aid:1-sid:16-cid:33", + unique_id="00:00:00:00:00:00_1_16_33", capabilities={"options": ["home", "sleep", "away"]}, state="home", ), @@ -164,16 +164,16 @@ async def test_ecobee3_setup_from_cache(hass, hass_storage): entity_registry = er.async_get(hass) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" async def test_ecobee3_setup_connection_failure(hass): @@ -204,16 +204,16 @@ async def test_ecobee3_setup_connection_failure(hass): await time_changed(hass, 5 * 60) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" async def test_ecobee3_add_sensors_at_runtime(hass): @@ -226,7 +226,7 @@ async def test_ecobee3_add_sensors_at_runtime(hass): await setup_test_accessories(hass, accessories) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") assert occ1 is None @@ -243,10 +243,10 @@ async def test_ecobee3_add_sensors_at_runtime(hass): await device_config_changed(hass, accessories) occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py index cf498a61e8137c..3d508df3a9ebb8 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py @@ -39,7 +39,7 @@ async def test_ecobee501_setup(hass): EntityTestInfo( entity_id="climate.my_ecobee", friendly_name="My ecobee", - unique_id="homekit-123456789016-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE @@ -59,7 +59,7 @@ async def test_ecobee501_setup(hass): EntityTestInfo( entity_id="binary_sensor.my_ecobee_occupancy", friendly_name="My ecobee Occupancy", - unique_id="homekit-123456789016-57", + unique_id="00:00:00:00:00:00_1_57", unit_of_measurement=None, state=STATE_ON, ), diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py index 20dae666c69dc8..88220279b0c45c 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py @@ -34,7 +34,7 @@ async def test_ecobee_occupancy_setup(hass): EntityTestInfo( entity_id="binary_sensor.master_fan", friendly_name="Master Fan", - unique_id="homekit-111111111111-56", + unique_id="00:00:00:00:00:00_1_56", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py index eab2de030dbbef..c1a73dc37fa03e 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -34,7 +34,7 @@ async def test_eve_degree_setup(hass): entities=[ EntityTestInfo( entity_id="sensor.eve_degree_aa11_temperature", - unique_id="homekit-AA00A0A00000-22", + unique_id="00:00:00:00:00:00_1_22", friendly_name="Eve Degree AA11 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, @@ -42,7 +42,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_humidity", - unique_id="homekit-AA00A0A00000-27", + unique_id="00:00:00:00:00:00_1_27", friendly_name="Eve Degree AA11 Humidity", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, @@ -50,7 +50,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_air_pressure", - unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:32", + unique_id="00:00:00:00:00:00_1_30_32", friendly_name="Eve Degree AA11 Air Pressure", unit_of_measurement=PRESSURE_HPA, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -58,7 +58,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_battery", - unique_id="homekit-AA00A0A00000-17", + unique_id="00:00:00:00:00:00_1_17", friendly_name="Eve Degree AA11 Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -67,7 +67,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="number.eve_degree_aa11_elevation", - unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:33", + unique_id="00:00:00:00:00:00_1_30_33", friendly_name="Eve Degree AA11 Elevation", capabilities={ "max": 9000, diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py index 292ab9c66ac529..e678b3bbbaaf23 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_energy.py @@ -19,7 +19,7 @@ ) -async def test_eve_degree_setup(hass): +async def test_eve_energy_setup(hass): """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "eve_energy.json") await setup_test_accessories(hass, accessories) @@ -38,13 +38,13 @@ async def test_eve_degree_setup(hass): entities=[ EntityTestInfo( entity_id="switch.eve_energy_50ff", - unique_id="homekit-AA00A0A00000-28", + unique_id="00:00:00:00:00:00_1_28", friendly_name="Eve Energy 50FF", state="off", ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_amps", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:33", + unique_id="00:00:00:00:00:00_1_28_33", friendly_name="Eve Energy 50FF Amps", unit_of_measurement=ELECTRIC_CURRENT_AMPERE, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -52,7 +52,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_volts", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:32", + unique_id="00:00:00:00:00:00_1_28_32", friendly_name="Eve Energy 50FF Volts", unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -60,7 +60,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_power", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:34", + unique_id="00:00:00:00:00:00_1_28_34", friendly_name="Eve Energy 50FF Power", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -68,22 +68,22 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_energy_kwh", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:35", + unique_id="00:00:00:00:00:00_1_28_35", friendly_name="Eve Energy 50FF Energy kWh", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="0.28999999165535", ), EntityTestInfo( entity_id="switch.eve_energy_50ff_lock_physical_controls", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:36", + unique_id="00:00:00:00:00:00_1_28_36", friendly_name="Eve Energy 50FF Lock Physical Controls", entity_category=EntityCategory.CONFIG, state="off", ), EntityTestInfo( entity_id="button.eve_energy_50ff_identify", - unique_id="homekit-AA00A0A00000-aid:1-sid:1-cid:3", + unique_id="00:00:00:00:00:00_1_1_3", friendly_name="Eve Energy 50FF Identify", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py index 2f01a2c404eece..33eb5e2497983d 100644 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_haa_fan.py @@ -46,7 +46,7 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="switch.haa_c718b3", friendly_name="HAA-C718B3", - unique_id="homekit-C718B3-2-8", + unique_id="00:00:00:00:00:00_2_8", state="off", ) ], @@ -56,7 +56,7 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="fan.haa_c718b3", friendly_name="HAA-C718B3", - unique_id="homekit-C718B3-1-8", + unique_id="00:00:00:00:00:00_1_8", state="on", supported_features=FanEntityFeature.SET_SPEED, capabilities={ @@ -66,14 +66,14 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="button.haa_c718b3_setup", friendly_name="HAA-C718B3 Setup", - unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1012", + unique_id="00:00:00:00:00:00_1_1010_1012", entity_category=EntityCategory.CONFIG, state="unknown", ), EntityTestInfo( entity_id="button.haa_c718b3_update", friendly_name="HAA-C718B3 Update", - unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1011", + unique_id="00:00:00:00:00:00_1_1010_1011", entity_category=EntityCategory.CONFIG, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py index 175e534f639f57..6848f4079b0369 100644 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py @@ -43,7 +43,7 @@ async def test_homeassistant_bridge_fan_setup(hass): EntityTestInfo( entity_id="fan.living_room_fan", friendly_name="Living Room Fan", - unique_id="homekit-fan.living_room_fan-8", + unique_id="00:00:00:00:00:00_1256851357_8", supported_features=( FanEntityFeature.DIRECTION | FanEntityFeature.SET_SPEED diff --git a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py index 1092bb4f82c25c..361bfbfe17869a 100644 --- a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py @@ -46,7 +46,7 @@ async def test_hue_bridge_setup(hass): capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="Hue dimmer switch battery", entity_category=EntityCategory.DIAGNOSTIC, - unique_id="homekit-6623462389072572-644245094400", + unique_id="00:00:00:00:00:00_6623462389072572_644245094400", unit_of_measurement=PERCENTAGE, state="100", ) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 99f34491e86849..2e3102d8f13b1d 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -46,7 +46,7 @@ async def test_koogeek_ls1_setup(hass): EntityTestInfo( entity_id="light.koogeek_ls1_20833f_light_strip", friendly_name="Koogeek-LS1-20833F Light Strip", - unique_id="homekit-AAAA011111111111-7", + unique_id="00:00:00:00:00:00_1_7", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="off", @@ -54,7 +54,7 @@ async def test_koogeek_ls1_setup(hass): EntityTestInfo( entity_id="button.koogeek_ls1_20833f_identify", friendly_name="Koogeek-LS1-20833F Identify", - unique_id="homekit-AAAA011111111111-aid:1-sid:1-cid:6", + unique_id="00:00:00:00:00:00_1_1_6", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index 7df1cee54d5e3f..ee8c273904c6dc 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -33,13 +33,13 @@ async def test_koogeek_p1eu_setup(hass): EntityTestInfo( entity_id="switch.koogeek_p1_a00aa0_outlet", friendly_name="Koogeek-P1-A00AA0 outlet", - unique_id="homekit-EUCP03190xxxxx48-7", + unique_id="00:00:00:00:00:00_1_7", state="off", ), EntityTestInfo( entity_id="sensor.koogeek_p1_a00aa0_power", friendly_name="Koogeek-P1-A00AA0 Power", - unique_id="homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22", + unique_id="00:00:00:00:00:00_1_21_22", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="5", diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 210fec0aafc4b1..91edf91156ab70 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -39,19 +39,19 @@ async def test_koogeek_sw2_setup(hass): EntityTestInfo( entity_id="switch.koogeek_sw2_187a91_switch_1", friendly_name="Koogeek-SW2-187A91 Switch 1", - unique_id="homekit-CNNT061751001372-8", + unique_id="00:00:00:00:00:00_1_8", state="off", ), EntityTestInfo( entity_id="switch.koogeek_sw2_187a91_switch_2", friendly_name="Koogeek-SW2-187A91 Switch 2", - unique_id="homekit-CNNT061751001372-11", + unique_id="00:00:00:00:00:00_1_11", state="off", ), EntityTestInfo( entity_id="sensor.koogeek_sw2_187a91_power", friendly_name="Koogeek-SW2-187A91 Power", - unique_id="homekit-CNNT061751001372-aid:1-sid:14-cid:18", + unique_id="00:00:00:00:00:00_1_14_18", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index 1bb31241023f2f..fb1c0d183d3ba1 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -39,7 +39,7 @@ async def test_lennox_e30_setup(hass): EntityTestInfo( entity_id="climate.lennox", friendly_name="Lennox", - unique_id="homekit-XXXXXXXX-100", + unique_id="00:00:00:00:00:00_1_100", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ), diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 22d29f7500ded8..4af74e0cd86d11 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -36,7 +36,7 @@ async def test_lg_tv(hass): EntityTestInfo( entity_id="media_player.lg_webos_tv_af80", friendly_name="LG webOS TV AF80", - unique_id="homekit-999AAAAAA999-48", + unique_id="00:00:00:00:00:00_1_48", supported_features=( SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE ), diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py index 9df8cf4e5aef9b..76c5bc70bfff0f 100644 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py @@ -41,7 +41,7 @@ async def test_lutron_caseta_bridge_setup(hass): EntityTestInfo( entity_id="fan.caseta_r_wireless_fan_speed_control", friendly_name="Caséta® Wireless Fan Speed Control", - unique_id="homekit-39024290-2", + unique_id="00:00:00:00:00:00_21474836482_2", unit_of_measurement=None, supported_features=1, state=STATE_OFF, diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py index 6db4140bd75cd3..86d8ebeca71cda 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ b/tests/components/homekit_controller/specific_devices/test_mss425f.py @@ -34,38 +34,38 @@ async def test_meross_mss425f_setup(hass): EntityTestInfo( entity_id="button.mss425f_15cc_identify", friendly_name="MSS425F-15cc Identify", - unique_id="homekit-HH41234-aid:1-sid:1-cid:2", + unique_id="00:00:00:00:00:00_1_1_2", entity_category=EntityCategory.DIAGNOSTIC, state=STATE_UNKNOWN, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_1", friendly_name="MSS425F-15cc Outlet-1", - unique_id="homekit-HH41234-12", + unique_id="00:00:00:00:00:00_1_12", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_2", friendly_name="MSS425F-15cc Outlet-2", - unique_id="homekit-HH41234-15", + unique_id="00:00:00:00:00:00_1_15", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_3", friendly_name="MSS425F-15cc Outlet-3", - unique_id="homekit-HH41234-18", + unique_id="00:00:00:00:00:00_1_18", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_4", friendly_name="MSS425F-15cc Outlet-4", - unique_id="homekit-HH41234-21", + unique_id="00:00:00:00:00:00_1_21", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_usb", friendly_name="MSS425F-15cc USB", - unique_id="homekit-HH41234-24", + unique_id="00:00:00:00:00:00_1_24", state=STATE_ON, ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py index 1a9c5bbbf6fd5f..5140b563a9a463 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ b/tests/components/homekit_controller/specific_devices/test_mss565.py @@ -33,7 +33,7 @@ async def test_meross_mss565_setup(hass): EntityTestInfo( entity_id="light.mss565_28da_dimmer_switch", friendly_name="MSS565-28da Dimmer Switch", - unique_id="homekit-BB1121-12", + unique_id="00:00:00:00:00:00_1_12", capabilities={"supported_color_modes": ["brightness"]}, state=STATE_ON, ), diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py index a5abe4ad2e7ed1..83404d9dd9965e 100644 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -34,7 +34,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="climate.mysa_85dda9_thermostat", friendly_name="Mysa-85dda9 Thermostat", - unique_id="homekit-AAAAAAA000-20", + unique_id="00:00:00:00:00:00_1_20", supported_features=ClimateEntityFeature.TARGET_TEMPERATURE, capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], @@ -46,7 +46,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="sensor.mysa_85dda9_current_humidity", friendly_name="Mysa-85dda9 Current Humidity", - unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:27", + unique_id="00:00:00:00:00:00_1_20_27", unit_of_measurement=PERCENTAGE, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="40", @@ -54,7 +54,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="sensor.mysa_85dda9_current_temperature", friendly_name="Mysa-85dda9 Current Temperature", - unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:25", + unique_id="00:00:00:00:00:00_1_20_25", unit_of_measurement=TEMP_CELSIUS, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="24.1", @@ -62,7 +62,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="light.mysa_85dda9_display", friendly_name="Mysa-85dda9 Display", - unique_id="homekit-AAAAAAA000-40", + unique_id="00:00:00:00:00:00_1_40", supported_features=0, capabilities={"supported_color_modes": ["brightness"]}, state="off", diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py index c66ea0d76a927c..4afb61b19f358b 100644 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py @@ -34,9 +34,11 @@ async def test_nanoleaf_nl55_setup(hass): EntityTestInfo( entity_id="light.nanoleaf_strip_3b32_nanoleaf_light_strip", friendly_name="Nanoleaf Strip 3B32 Nanoleaf Light Strip", - unique_id="homekit-AAAA011111111111-19", + unique_id="00:00:00:00:00:00_1_19", supported_features=0, capabilities={ + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2127, "max_mireds": 470, "min_mireds": 153, "supported_color_modes": ["color_temp", "hs"], @@ -46,21 +48,21 @@ async def test_nanoleaf_nl55_setup(hass): EntityTestInfo( entity_id="button.nanoleaf_strip_3b32_identify", friendly_name="Nanoleaf Strip 3B32 Identify", - unique_id="homekit-AAAA011111111111-aid:1-sid:1-cid:2", + unique_id="00:00:00:00:00:00_1_1_2", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", ), EntityTestInfo( entity_id="sensor.nanoleaf_strip_3b32_thread_capabilities", friendly_name="Nanoleaf Strip 3B32 Thread Capabilities", - unique_id="homekit-AAAA011111111111-aid:1-sid:31-cid:115", + unique_id="00:00:00:00:00:00_1_31_115", entity_category=EntityCategory.DIAGNOSTIC, state="border_router_capable", ), EntityTestInfo( entity_id="sensor.nanoleaf_strip_3b32_thread_status", friendly_name="Nanoleaf Strip 3B32 Thread Status", - unique_id="homekit-AAAA011111111111-aid:1-sid:31-cid:117", + unique_id="00:00:00:00:00:00_1_31_117", entity_category=EntityCategory.DIAGNOSTIC, state="border_router", ), diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py index 188bbaffedd01d..9ff84c4570119f 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py @@ -35,7 +35,7 @@ async def test_netamo_doorbell_setup(hass): EntityTestInfo( entity_id="camera.netatmo_doorbell_g738658", friendly_name="Netatmo-Doorbell-g738658", - unique_id="homekit-g738658-aid:1", + unique_id="00:00:00:00:00:00_1", state="idle", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py index b2c83a005f80ab..3f46ffdc9fae2d 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py @@ -35,14 +35,14 @@ async def test_netamo_smart_co_alarm_setup(hass): EntityTestInfo( entity_id="binary_sensor.smart_co_alarm_carbon_monoxide_sensor", friendly_name="Smart CO Alarm Carbon Monoxide Sensor", - unique_id="homekit-1234-22", + unique_id="00:00:00:00:00:00_1_22", state="off", ), EntityTestInfo( entity_id="binary_sensor.smart_co_alarm_low_battery", friendly_name="Smart CO Alarm Low Battery", entity_category=EntityCategory.DIAGNOSTIC, - unique_id="homekit-1234-36", + unique_id="00:00:00:00:00:00_1_36", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py new file mode 100644 index 00000000000000..5769cc81129742 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py @@ -0,0 +1,45 @@ +""" +Regression tests for Netamo Healthy Home Coach. + +https://github.com/home-assistant/core/issues/73360 +""" +from homeassistant.components.sensor import SensorStateClass + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_netamo_smart_co_alarm_setup(hass): + """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Healthy Home Coach", + model="Healthy Home Coach", + manufacturer="Netatmo", + sw_version="59", + hw_version="", + serial_number="1234", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.healthy_home_coach_noise", + friendly_name="Healthy Home Coach Noise", + unique_id="00:00:00:00:00:00_1_20_21", + state="0", + unit_of_measurement="dB", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py index ecea2cdafbb538..c93493f38b5ebd 100644 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py @@ -34,49 +34,49 @@ async def test_rainmachine_pro_8_setup(hass): EntityTestInfo( entity_id="switch.rainmachine_00ce4a", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-512", + unique_id="00:00:00:00:00:00_1_512", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_2", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-768", + unique_id="00:00:00:00:00:00_1_768", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_3", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1024", + unique_id="00:00:00:00:00:00_1_1024", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_4", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1280", + unique_id="00:00:00:00:00:00_1_1280", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_5", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1536", + unique_id="00:00:00:00:00:00_1_1536", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_6", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1792", + unique_id="00:00:00:00:00:00_1_1792", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_7", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-2048", + unique_id="00:00:00:00:00:00_1_2048", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_8", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-2304", + unique_id="00:00:00:00:00:00_1_2304", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py index a0c84472429f40..1e572683ce3dc5 100644 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py @@ -47,7 +47,7 @@ async def test_ryse_smart_bridge_setup(hass): EntityTestInfo( entity_id="cover.master_bath_south_ryse_shade", friendly_name="Master Bath South RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-2-48", + unique_id="00:00:00:00:00:00_2_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -56,7 +56,7 @@ async def test_ryse_smart_bridge_setup(hass): friendly_name="Master Bath South RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-2-64", + unique_id="00:00:00:00:00:00_2_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -75,7 +75,7 @@ async def test_ryse_smart_bridge_setup(hass): EntityTestInfo( entity_id="cover.ryse_smartshade_ryse_shade", friendly_name="RYSE SmartShade RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-3-48", + unique_id="00:00:00:00:00:00_3_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -84,7 +84,7 @@ async def test_ryse_smart_bridge_setup(hass): friendly_name="RYSE SmartShade RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-3-64", + unique_id="00:00:00:00:00:00_3_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -126,7 +126,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.lr_left_ryse_shade", friendly_name="LR Left RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-2-48", + unique_id="00:00:00:00:00:00_2_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -135,7 +135,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="LR Left RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-2-64", + unique_id="00:00:00:00:00:00_2_64", unit_of_measurement=PERCENTAGE, state="89", ), @@ -154,7 +154,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.lr_right_ryse_shade", friendly_name="LR Right RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-3-48", + unique_id="00:00:00:00:00:00_3_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -163,7 +163,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="LR Right RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-3-64", + unique_id="00:00:00:00:00:00_3_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -182,7 +182,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.br_left_ryse_shade", friendly_name="BR Left RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-4-48", + unique_id="00:00:00:00:00:00_4_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -191,7 +191,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="BR Left RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-4-64", + unique_id="00:00:00:00:00:00_4_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -210,7 +210,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.rzss_ryse_shade", friendly_name="RZSS RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-5-48", + unique_id="00:00:00:00:00:00_5_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -219,7 +219,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="RZSS RYSE Shade Battery", - unique_id="homekit-00:00:00:00:00:00-5-64", + unique_id="00:00:00:00:00:00_5_64", unit_of_measurement=PERCENTAGE, state="0", ), diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py index 0a59ec6f70a0b2..e1b55f6bd8898c 100644 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py @@ -31,7 +31,7 @@ async def test_schlage_sense_setup(hass): EntityTestInfo( entity_id="lock.sense_lock_mechanism", friendly_name="SENSE Lock Mechanism", - unique_id="homekit-AAAAAAA000-30", + unique_id="00:00:00:00:00:00_1_30", supported_features=0, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py index ba24bdeef96e19..9a5edeb45b28bd 100644 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py @@ -36,7 +36,7 @@ async def test_simpleconnect_fan_setup(hass): EntityTestInfo( entity_id="fan.simpleconnect_fan_06f674_hunter_fan", friendly_name="SIMPLEconnect Fan-06F674 Hunter Fan", - unique_id="homekit-1234567890abcd-8", + unique_id="00:00:00:00:00:00_1_8", supported_features=FanEntityFeature.DIRECTION | FanEntityFeature.SET_SPEED, capabilities={ diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py index 8a5102e0a87fd0..a82d995d4d18af 100644 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py @@ -51,7 +51,7 @@ async def test_velux_cover_setup(hass): EntityTestInfo( entity_id="cover.velux_window_roof_window", friendly_name="VELUX Window Roof Window", - unique_id="homekit-1111111a114a111a-8", + unique_id="00:00:00:00:00:00_3_8", supported_features=CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, @@ -73,7 +73,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_temperature_sensor", friendly_name="VELUX Sensor Temperature sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-8", + unique_id="00:00:00:00:00:00_2_8", unit_of_measurement=TEMP_CELSIUS, state="18.9", ), @@ -81,7 +81,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_humidity_sensor", friendly_name="VELUX Sensor Humidity sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-11", + unique_id="00:00:00:00:00:00_2_11", unit_of_measurement=PERCENTAGE, state="58", ), @@ -89,7 +89,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_carbon_dioxide_sensor", friendly_name="VELUX Sensor Carbon Dioxide sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-14", + unique_id="00:00:00:00:00:00_2_14", unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state="400", ), diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py index 7c3262f3098a58..b07a10cf17d7ef 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -36,7 +36,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="humidifier.vocolinc_flowerbud_0d324b", friendly_name="VOCOlinc-Flowerbud-0d324b", - unique_id="homekit-AM01121849000327-30", + unique_id="00:00:00:00:00:00_1_30", supported_features=HumidifierEntityFeature.MODES, capabilities={ "available_modes": ["normal", "auto"], @@ -48,7 +48,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="light.vocolinc_flowerbud_0d324b_mood_light", friendly_name="VOCOlinc-Flowerbud-0d324b Mood Light", - unique_id="homekit-AM01121849000327-9", + unique_id="00:00:00:00:00:00_1_9", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="on", @@ -56,7 +56,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", - unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:38", + unique_id="00:00:00:00:00:00_1_30_38", capabilities={ "max": 5, "min": 1, @@ -69,7 +69,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", - unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:33", + unique_id="00:00:00:00:00:00_1_30_33", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, state="45.0", diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 4037a44898e44d..7ced8979c8a8dd 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -2,6 +2,7 @@ from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT +from homeassistant.helpers import entity_registry as er from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -15,6 +16,21 @@ async def test_vocolinc_vp3_setup(hass): """Test that a VOCOlinc VP3 can be correctly setup in HA.""" + + entity_registry = er.async_get(hass) + outlet = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + "homekit-EU0121203xxxxx07-48", + suggested_object_id="original_vocolinc_vp3_outlet", + ) + sensor = entity_registry.async_get_or_create( + "sensor", + "homekit_controller", + "homekit-EU0121203xxxxx07-aid:1-sid:48-cid:97", + suggested_object_id="original_vocolinc_vp3_power", + ) + accessories = await setup_accessories_from_file(hass, "vocolinc_vp3.json") await setup_test_accessories(hass, accessories) @@ -31,15 +47,15 @@ async def test_vocolinc_vp3_setup(hass): devices=[], entities=[ EntityTestInfo( - entity_id="switch.vocolinc_vp3_123456_outlet", + entity_id="switch.original_vocolinc_vp3_outlet", friendly_name="VOCOlinc-VP3-123456 Outlet", - unique_id="homekit-EU0121203xxxxx07-48", + unique_id="00:00:00:00:00:00_1_48", state="on", ), EntityTestInfo( - entity_id="sensor.vocolinc_vp3_123456_power", + entity_id="sensor.original_vocolinc_vp3_power", friendly_name="VOCOlinc-VP3-123456 Power", - unique_id="homekit-EU0121203xxxxx07-aid:1-sid:48-cid:97", + unique_id="00:00:00:00:00:00_1_48_97", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", @@ -47,3 +63,12 @@ async def test_vocolinc_vp3_setup(hass): ], ), ) + + assert ( + entity_registry.async_get(outlet.entity_id).unique_id + == "00:00:00:00:00:00_1_48" + ) + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == "00:00:00:00:00:00_1_48_97" + ) diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 46979bd41f3caf..2c2ff92ccb64ca 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_security_system_service(accessory): @@ -119,3 +121,20 @@ async def test_switch_read_alarm_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.state == "triggered" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a alarm_control_panel unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + alarm_control_panel_entry = entity_registry.async_get_or_create( + "alarm_control_panel", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_security_system_service) + + assert ( + entity_registry.async_get(alarm_control_panel_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index e9cd9284332b40..7e926910da1815 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -3,8 +3,9 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_motion_sensor_service(accessory): @@ -169,3 +170,20 @@ async def test_leak_sensor_read_state(hass, utcnow): assert state.state == "on" assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a binary_sensor unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_leak_sensor_service) + + assert ( + entity_registry.async_get(binary_sensor_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 58c1feb8900679..77551668ea573a 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_switch_with_setup_button(accessory): @@ -89,3 +91,19 @@ async def test_ecobee_clear_hold_press_button(hass): CharacteristicsTypes.VENDOR_ECOBEE_CLEAR_HOLD: True, }, ) + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a button unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + button_entry = entity_registry.async_get_or_create( + "button", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:1-cid:2", + ) + await setup_test_component(hass, create_switch_with_ecobee_clear_hold_button) + assert ( + entity_registry.async_get(button_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_1_2" + ) diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index e0ba609b30e5e9..f4207ca4ca98e0 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -5,8 +5,9 @@ from aiohomekit.testing import FAKE_CAMERA_IMAGE from homeassistant.components import camera +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_camera(accessory): @@ -14,6 +15,22 @@ def create_camera(accessory): accessory.add_service(ServicesTypes.CAMERA_RTP_STREAM_MANAGEMENT) +async def test_migrate_unique_ids(hass, utcnow): + """Test migrating entity unique ids.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + camera = entity_registry.async_get_or_create( + "camera", + "homekit_controller", + f"homekit-0001-aid:{aid}", + ) + await setup_test_component(hass, create_camera) + assert ( + entity_registry.async_get(camera.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}" + ) + + async def test_read_state(hass, utcnow): """Test reading the state of a HomeKit camera.""" helper = await setup_test_component(hass, create_camera) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 0f669c9c51f87c..0f10f0f9fa0f4d 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -17,8 +17,9 @@ SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component # Test thermostat devices @@ -943,3 +944,19 @@ async def test_heater_cooler_turn_off(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "off" assert state.attributes["hvac_action"] == "off" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a switch unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + climate_entry = entity_registry.async_get_or_create( + "climate", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_heater_cooler_service) + assert ( + entity_registry.async_get(climate_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 9db07a45d166e5..b853989ab151c9 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -9,7 +9,6 @@ IDENTIFIER_ACCESSORY_ID, IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, - IDENTIFIER_SERIAL_NUMBER, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -36,7 +35,6 @@ class DeviceMigrationTest: manufacturer="RYSE Inc.", before={ (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), - (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "0401.3521.0679"), }, after={(IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1")}, ), @@ -55,11 +53,9 @@ class DeviceMigrationTest: manufacturer="Philips Lighting", before={ (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), - (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "123456"), }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), - (IDENTIFIER_SERIAL_NUMBER, "123456"), }, ), # Test migrating a Hue remote - it has a valid serial number @@ -72,7 +68,6 @@ class DeviceMigrationTest: }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:6623462389072572"), - (IDENTIFIER_SERIAL_NUMBER, "6623462389072572"), }, ), # Test migrating a Koogeek LS1. This is just for completeness (testing hub and hub-less devices) @@ -85,7 +80,6 @@ class DeviceMigrationTest: }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), - (IDENTIFIER_SERIAL_NUMBER, "AAAA011111111111"), }, ), ] diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 15422f2f0bcc5a..6ceb57f5e09c08 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_window_covering_service(accessory): @@ -277,3 +279,20 @@ async def test_read_door_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.attributes["obstruction-detected"] is True + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a cover unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + cover_entry = entity_registry.async_get_or_create( + "cover", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_garage_door_opener_service) + + assert ( + entity_registry.async_get(cover_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index de13772b5a1235..855f426da13acb 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_fan_service(accessory): @@ -805,3 +807,20 @@ async def test_v2_set_percentage_non_standard_rotation_range(hass, utcnow): CharacteristicsTypes.ACTIVE: 0, }, ) + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a fan unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + fan_entry = entity_registry.async_get_or_create( + "fan", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_fanv2_service_non_standard_rotation_range) + + assert ( + entity_registry.async_get(fan_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index da981e9eac0eb4..1128459c4a645a 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -3,8 +3,9 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.humidifier import DOMAIN, MODE_AUTO, MODE_NORMAL +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_humidifier_service(accessory): @@ -436,3 +437,20 @@ async def test_dehumidifier_target_humidity_modes(hass, utcnow): ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 73 + + +async def test_migrate_entity_ids(hass, utcnow): + """Test that we can migrate humidifier entity ids.""" + aid = get_next_aid() + + entity_registry = er.async_get(hass) + humidifier_entry = entity_registry.async_get_or_create( + "humidifier", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_humidifier_service) + assert ( + entity_registry.async_get(humidifier_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 726be15a32c0aa..31604f2b1dd7b1 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -9,8 +9,9 @@ ColorMode, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component LIGHT_BULB_NAME = "TestDevice" LIGHT_BULB_ENTITY_ID = "light.testdevice" @@ -335,3 +336,47 @@ async def test_light_unloaded_removed(hass, utcnow): # Make sure entity is removed assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a light unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_lightbulb_service_with_color_temp) + + assert ( + entity_registry.async_get(light_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) + + +async def test_only_migrate_once(hass, utcnow): + """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + old_light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + new_light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"00:00:00:00:00:00_{aid}_8", + ) + await setup_test_component(hass, create_lightbulb_service_with_color_temp) + + assert ( + entity_registry.async_get(old_light_entry.entity_id).unique_id + == f"homekit-00:00:00:00:00:00-{aid}-8" + ) + + assert ( + entity_registry.async_get(new_light_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index af21f26a012de4..719ff66c766dee 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_lock_service(accessory): @@ -112,3 +114,20 @@ async def test_switch_read_lock_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.state == "unlocking" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a lock unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + lock_entry = entity_registry.async_get_or_create( + "lock", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_lock_service) + + assert ( + entity_registry.async_get(lock_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 7fb8c4edb2a230..829f28cf341e60 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -6,7 +6,9 @@ from aiohomekit.model.services import ServicesTypes import pytest -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_tv_service(accessory): @@ -364,3 +366,20 @@ async def test_tv_set_source_fail(hass, utcnow): state = await helper.poll_and_get_state() assert state.attributes["source"] == "HDMI 1" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a media_player unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + media_player_entry = entity_registry.async_get_or_create( + "media_player", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_tv_service_with_target_media_state) + + assert ( + entity_registry.async_get(media_player_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index 6d060416861274..cc6950e0f90a96 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_switch_with_spray_level(accessory): @@ -26,6 +28,24 @@ def create_switch_with_spray_level(accessory): return service +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a number unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + number = entity_registry.async_get_or_create( + "number", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:9", + suggested_object_id="testdevice_spray_quantity", + ) + await setup_test_component(hass, create_switch_with_spray_level) + + assert ( + entity_registry.async_get(number.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_9" + ) + + async def test_read_number(hass, utcnow): """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 22cd53d7a314fa..d18f0b97ecc642 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -3,7 +3,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_service_with_ecobee_mode(accessory: Accessory): @@ -19,6 +21,25 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +async def test_migrate_unique_id(hass, utcnow): + """Test we can migrate a select unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + select = entity_registry.async_get_or_create( + "select", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:14", + suggested_object_id="testdevice_current_mode", + ) + + await setup_test_component(hass, create_service_with_ecobee_mode) + + assert ( + entity_registry.async_get(select.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_14" + ) + + async def test_read_current_mode(hass, utcnow): """Test that Ecobee mode can be correctly read and show as human readable text.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 4bd6612026c74c..b769a916082976 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -13,6 +13,7 @@ thread_status_to_str, ) from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.helpers import entity_registry as er from .common import TEST_DEVICE_SERVICE_INFO, Helper, setup_test_component @@ -361,7 +362,6 @@ async def test_rssi_sensor( hass, utcnow, entity_registry_enabled_by_default, enable_bluetooth ): """Test an rssi sensor.""" - inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) class FakeBLEPairing(FakePairing): @@ -378,3 +378,38 @@ def transport(self): hass, create_battery_level_sensor, suffix="battery", connection="BLE" ) assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" + + +async def test_migrate_rssi_sensor_unique_id( + hass, utcnow, entity_registry_enabled_by_default, enable_bluetooth +): + """Test an rssi sensor unique id migration.""" + entity_registry = er.async_get(hass) + rssi_sensor = entity_registry.async_get_or_create( + "sensor", + "homekit_controller", + "homekit-0001-rssi", + suggested_object_id="renamed_rssi", + ) + + inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) + + class FakeBLEPairing(FakePairing): + """Fake BLE pairing.""" + + @property + def transport(self): + return Transport.BLE + + with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, create_battery_level_sensor, suffix="battery", connection="BLE" + ) + assert hass.states.get("sensor.renamed_rssi").state == "-56" + + assert ( + entity_registry.async_get(rssi_sensor.entity_id).unique_id + == "00:00:00:00:00:00_rssi" + ) diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index a034624bd6070e..1e9b1cab730a9d 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -7,7 +7,9 @@ ) from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_switch_service(accessory): @@ -215,3 +217,30 @@ async def test_char_switch_read_state(hass, utcnow): {CharacteristicsTypes.VENDOR_AQARA_PAIRING_MODE: False}, ) assert switch_1.state == "off" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a switch unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + switch_entry = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + switch_entry_2 = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:9", + ) + await setup_test_component(hass, create_char_switch_service, suffix="pairing_mode") + + assert ( + entity_registry.async_get(switch_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) + + assert ( + entity_registry.async_get(switch_entry_2.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_9" + ) diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c8238c75643edc..d405a713edb39f 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -609,7 +609,7 @@ async def test_sensor_entity_total_liters( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER assert state.attributes.get(ATTR_ICON) == "mdi:gauge" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 7a4202c1a674cb..a4249a1efb618b 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -198,7 +198,17 @@ async def unauth_handler(request): manager: IpBanManager = app[KEY_BAN_MANAGER] - assert await async_setup_component(hass, "hassio", {"hassio": {}}) + with patch( + "homeassistant.components.hassio.HassIO.get_resolution_info", + return_value={ + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + ): + assert await async_setup_component(hass, "hassio", {"hassio": {}}) m_open = mock_open() diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 84f66e8f0ab619..56c177a3602dd1 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -5,6 +5,7 @@ from huawei_lte_api.enums.client import ResponseCodeEnum from huawei_lte_api.enums.user import LoginErrorEnum, LoginStateEnum, PasswordTypeEnum import pytest +import requests.exceptions from requests.exceptions import ConnectionError from requests_mock import ANY @@ -119,27 +120,66 @@ def login_requests_mock(requests_mock): @pytest.mark.parametrize( - ("code", "errors"), + ("request_outcome", "fixture_override", "errors"), ( - (LoginErrorEnum.USERNAME_WRONG, {CONF_USERNAME: "incorrect_username"}), - (LoginErrorEnum.PASSWORD_WRONG, {CONF_PASSWORD: "incorrect_password"}), ( - LoginErrorEnum.USERNAME_PWD_WRONG, + { + "text": f"{LoginErrorEnum.USERNAME_WRONG}", + }, + {}, + {CONF_USERNAME: "incorrect_username"}, + ), + ( + { + "text": f"{LoginErrorEnum.PASSWORD_WRONG}", + }, + {}, + {CONF_PASSWORD: "incorrect_password"}, + ), + ( + { + "text": f"{LoginErrorEnum.USERNAME_PWD_WRONG}", + }, + {}, {CONF_USERNAME: "invalid_auth"}, ), - (LoginErrorEnum.USERNAME_PWD_OVERRUN, {"base": "login_attempts_exceeded"}), - (ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, {"base": "response_error"}), + ( + { + "text": f"{LoginErrorEnum.USERNAME_PWD_OVERRUN}", + }, + {}, + {"base": "login_attempts_exceeded"}, + ), + ( + { + "text": f"{ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN}", + }, + {}, + {"base": "response_error"}, + ), + ({}, {CONF_URL: "/foo/bar"}, {CONF_URL: "invalid_url"}), + ( + { + "exc": requests.exceptions.Timeout, + }, + {}, + {CONF_URL: "connection_timeout"}, + ), ), ) -async def test_login_error(hass, login_requests_mock, code, errors): +async def test_login_error( + hass, login_requests_mock, request_outcome, fixture_override, errors +): """Test we show user form with appropriate error on response failure.""" login_requests_mock.request( ANY, f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", - text=f"{code}", + **request_outcome, ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={**FIXTURE_USER_INPUT, **fixture_override}, ) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -170,7 +210,43 @@ async def test_success(hass, login_requests_mock): assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] -async def test_ssdp(hass): +@pytest.mark.parametrize( + ("upnp_data", "expected_result"), + ( + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ssdp.ATTR_UPNP_SERIAL: "00000000", + }, + { + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "user", + "errors": {}, + }, + ), + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ssdp.ATTR_UPNP_SERIAL + }, + { + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "user", + "errors": {}, + }, + ), + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Some other device", + }, + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "not_huawei_lte", + }, + ), + ), +) +async def test_ssdp(hass, upnp_data, expected_result): """Test SSDP discovery initiates config properly.""" url = "http://192.168.100.1/" context = {"source": config_entries.SOURCE_SSDP} @@ -183,21 +259,93 @@ async def test_ssdp(hass): ssdp_location="http://192.168.100.1:60957/rootDesc.xml", upnp={ ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + **upnp_data, }, ), ) + for k, v in expected_result.items(): + assert result[k] == v + if result.get("data_schema"): + result["data_schema"]({})[CONF_URL] == url + + +@pytest.mark.parametrize( + ("login_response_text", "expected_result", "expected_entry_data"), + ( + ( + "OK", + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "reauth_successful", + }, + FIXTURE_USER_INPUT, + ), + ( + f"{LoginErrorEnum.PASSWORD_WRONG}", + { + "type": data_entry_flow.FlowResultType.FORM, + "errors": {CONF_PASSWORD: "incorrect_password"}, + "step_id": "reauth_confirm", + }, + {**FIXTURE_USER_INPUT, CONF_PASSWORD: "invalid-password"}, + ), + ), +) +async def test_reauth( + hass, login_requests_mock, login_response_text, expected_result, expected_entry_data +): + """Test reauth.""" + mock_entry_data = {**FIXTURE_USER_INPUT, CONF_PASSWORD: "invalid-password"} + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UNIQUE_ID, + data=mock_entry_data, + title="Reauth canary", + ) + entry.add_to_hass(hass) + + context = { + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context=context, data=entry.data + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["data_schema"]({})[CONF_URL] == url + assert result["step_id"] == "reauth_confirm" + assert result["data_schema"]({}) == { + CONF_USERNAME: mock_entry_data[CONF_USERNAME], + CONF_PASSWORD: mock_entry_data[CONF_PASSWORD], + } + assert not result["errors"] + + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text=login_response_text, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME], + CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + for k, v in expected_result.items(): + assert result[k] == v + for k, v in expected_entry_data.items(): + assert entry.data[k] == v async def test_options(hass): diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index 28859e6133f9c3..16f3b136180bdc 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -20,7 +20,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test humidifier registered attributes to be excluded.""" await async_setup_component( hass, humidifier.DOMAIN, {humidifier.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py index 6a46e063501bab..b4db99dbe40ca1 100644 --- a/tests/components/iaqualink/conftest.py +++ b/tests/components/iaqualink/conftest.py @@ -1,6 +1,6 @@ """Configuration for iAqualink tests.""" import random -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, PropertyMock, patch from iaqualink.client import AqualinkClient from iaqualink.device import AqualinkDevice @@ -47,14 +47,31 @@ def get_aqualink_system(aqualink, cls=None, data=None): return cls(aqualink=aqualink, data=data) -def get_aqualink_device(system, cls=None, data=None): +def get_aqualink_device(system, name, cls=None, data=None): """Create aqualink device.""" if cls is None: cls = AqualinkDevice + # AqualinkDevice doesn't implement some of the properties since it's left to + # sub-classes for them to do. Provide a basic implementation here for the + # benefits of the test suite. + attrs = { + "name": name, + "manufacturer": "Jandy", + "model": "Device", + "label": name.upper(), + } + + for k, v in attrs.items(): + patcher = patch.object(cls, k, new_callable=PropertyMock) + mock = patcher.start() + mock.return_value = v + if data is None: data = {} + data["name"] = name + return cls(system=system, data=data) @@ -72,7 +89,7 @@ def config_fixture(): @pytest.fixture(name="config_entry") def config_entry_fixture(): - """Create a mock HEOS config entry.""" + """Create a mock config entry.""" return MockConfigEntry( domain=DOMAIN, data=MOCK_DATA, diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index bd2e072d2136bf..3f2b822da81bd7 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -4,15 +4,15 @@ import logging from unittest.mock import AsyncMock, patch -from iaqualink.device import ( - AqualinkAuxToggle, - AqualinkBinarySensor, - AqualinkDevice, - AqualinkLightToggle, - AqualinkSensor, - AqualinkThermostat, -) from iaqualink.exception import AqualinkServiceException +from iaqualink.systems.iaqua.device import ( + IaquaAuxSwitch, + IaquaBinarySensor, + IaquaLightSwitch, + IaquaSensor, + IaquaThermostat, +) +from iaqualink.systems.iaqua.system import IaquaSystem from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -101,7 +101,7 @@ async def test_setup_devices_exception(hass, config_entry, client): """Test setup encountering an exception while retrieving devices.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} with patch( @@ -124,10 +124,10 @@ async def test_setup_all_good_no_recognized_devices(hass, config_entry, client): """Test setup ending in no devices recognized.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} - device = get_aqualink_device(system, AqualinkDevice, data={"name": "dev_1"}) + device = get_aqualink_device(system, name="dev_1") devices = {device.name: device} with patch( @@ -161,19 +161,15 @@ async def test_setup_all_good_all_device_types(hass, config_entry, client): """Test setup ending in one device of each type recognized.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} devices = [ - get_aqualink_device(system, AqualinkAuxToggle, data={"name": "aux_1"}), - get_aqualink_device( - system, AqualinkBinarySensor, data={"name": "freeze_protection"} - ), - get_aqualink_device(system, AqualinkLightToggle, data={"name": "aux_2"}), - get_aqualink_device(system, AqualinkSensor, data={"name": "ph"}), - get_aqualink_device( - system, AqualinkThermostat, data={"name": "pool_set_point"} - ), + get_aqualink_device(system, name="aux_1", cls=IaquaAuxSwitch), + get_aqualink_device(system, name="freeze_protection", cls=IaquaBinarySensor), + get_aqualink_device(system, name="aux_2", cls=IaquaLightSwitch), + get_aqualink_device(system, name="ph", cls=IaquaSensor), + get_aqualink_device(system, name="pool_set_point", cls=IaquaThermostat), ] devices = {d.name: d for d in devices} @@ -207,7 +203,7 @@ async def test_multiple_updates(hass, config_entry, caplog, client): """Test all possible results of online status transition after update.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} system.get_devices = AsyncMock(return_value={}) @@ -269,7 +265,7 @@ def set_online_to_false(): system.update.side_effect = set_online_to_true await _ffwd_next_update_interval(hass) assert len(caplog.records) == 1 - assert "Reconnected" in caplog.text + assert "reconnected" in caplog.text # False -> None / ServiceException system.online = False @@ -292,7 +288,7 @@ def set_online_to_false(): system.update.side_effect = set_online_to_true await _ffwd_next_update_interval(hass) assert len(caplog.records) == 1 - assert "Reconnected" in caplog.text + assert "reconnected" in caplog.text # None -> False system.online = None @@ -311,11 +307,11 @@ async def test_entity_assumed_and_available(hass, config_entry, client): """Test assumed_state and_available properties for all values of online.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} light = get_aqualink_device( - system, AqualinkLightToggle, data={"name": "aux_1", "state": "1"} + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} ) devices = {d.name: d for d in [light]} system.get_devices = AsyncMock(return_value=devices) diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py index f10bc65ed337ce..56d5eb784670cb 100644 --- a/tests/components/ibeacon/__init__.py +++ b/tests/components/ibeacon/__init__.py @@ -58,7 +58,21 @@ service_uuids=[], source="local", ) - +TESLA_TRANSIENT = BluetoothServiceInfo( + address="CC:CC:CC:CC:CC:CC", + rssi=-60, + name="S6da7c9389bd5452cC", + manufacturer_data={ + 76: b"\x02\x15t'\x8b\xda\xb6DE \x8f\x0cr\x0e\xaf\x05\x995\x00\x00[$\xc5" + }, + service_data={}, + service_uuids=[], + source="hci0", +) +TESLA_TRANSIENT_BLE_DEVICE = BLEDevice( + address="CC:CC:CC:CC:CC:CC", + name="S6da7c9389bd5452cC", +) FEASY_BEACON_BLE_DEVICE = BLEDevice( address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 5ea19914ee45df..25ce7154a37816 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -2,16 +2,27 @@ from dataclasses import replace +from datetime import timedelta import pytest -from homeassistant.components.ibeacon.const import DOMAIN +from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.const import STATE_HOME from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.util import dt as dt_util -from . import BLUECHARM_BEACON_SERVICE_INFO, BLUECHARM_BEACON_SERVICE_INFO_DBUS +from . import ( + BLUECHARM_BEACON_SERVICE_INFO, + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + TESLA_TRANSIENT, + TESLA_TRANSIENT_BLE_DEVICE, +) -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) @pytest.fixture(autouse=True) @@ -195,3 +206,49 @@ async def test_rotating_major_minor_and_mac_no_name(hass): await hass.async_block_till_done() assert len(hass.states.async_entity_ids("device_tracker")) == before_entity_count + + +async def test_ignore_transient_devices_unless_we_see_them_a_few_times(hass): + """Test we ignore transient devices unless we see them a few times.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + TESLA_TRANSIENT, + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == before_entity_count + + for i in range(3, 17): + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2 * i), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) > before_entity_count + + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME + + await hass.config_entries.async_reload(entry.entry_id) + + await hass.async_block_till_done() + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index e7f68379343f61..68573866965053 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -16,7 +16,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index e469536549a701..53b2b9615f6ea7 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -16,7 +16,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index bbdd0446e563f8..d5c95d5951f25b 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -16,7 +16,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index f736d450e7abb7..325234e069b442 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -22,7 +22,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 2931132bafc340..457fc0feccc467 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -16,7 +16,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index 928399cd93953c..9557f656465035 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -23,7 +23,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index 35099c405bb97e..4a002140437be9 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -1 +1,111 @@ """Tests for the IPMA component.""" +from collections import namedtuple +from datetime import datetime, timezone + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME + +ENTRY_CONFIG = { + CONF_NAME: "Home Town", + CONF_LATITUDE: "1", + CONF_LONGITUDE: "2", + CONF_MODE: "hourly", +} + + +class MockLocation: + """Mock Location from pyipma.""" + + async def observation(self, api): + """Mock Observation.""" + Observation = namedtuple( + "Observation", + [ + "accumulated_precipitation", + "humidity", + "pressure", + "radiation", + "temperature", + "wind_direction", + "wind_intensity_km", + ], + ) + + return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) + + async def forecast(self, api, period): + """Mock Forecast.""" + Forecast = namedtuple( + "Forecast", + [ + "feels_like_temperature", + "forecast_date", + "forecasted_hours", + "humidity", + "max_temperature", + "min_temperature", + "precipitation_probability", + "temperature", + "update_date", + "weather_type", + "wind_direction", + "wind_strength", + ], + ) + + WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) + + if period == 24: + return [ + Forecast( + None, + datetime(2020, 1, 16, 0, 0, 0), + 24, + None, + 16.2, + 10.6, + "100.0", + 13.4, + "2020-01-15T07:51:00", + WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), + "S", + "10", + ), + ] + if period == 1: + return [ + Forecast( + "7.7", + datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), + "S", + "32.7", + ), + Forecast( + "5.7", + datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(1, "Clear sky", "C\u00e9u limpo"), + "S", + "32.7", + ), + ] + + name = "HomeTown" + station = "HomeTown Station" + station_latitude = 0 + station_longitude = 0 + global_id_local = 1130600 + id_station = 1200545 diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index ea4b0b510e7326..e254ba402fb96e 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -3,21 +3,14 @@ from unittest.mock import Mock, patch from homeassistant.components.ipma import DOMAIN, config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .test_weather import MockLocation +from . import MockLocation from tests.common import MockConfigEntry, mock_registry -ENTRY_CONFIG = { - CONF_NAME: "Home Town", - CONF_LATITUDE: "1", - CONF_LONGITUDE: "2", - CONF_MODE: "hourly", -} - async def test_show_config_form(): """Test show configuration form.""" diff --git a/tests/components/ipma/test_init.py b/tests/components/ipma/test_init.py new file mode 100644 index 00000000000000..8dd808b1b1baa6 --- /dev/null +++ b/tests/components/ipma/test_init.py @@ -0,0 +1,57 @@ +"""Test the IPMA integration.""" + +from unittest.mock import patch + +from pyipma import IPMAException + +from homeassistant.components.ipma import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE + +from .test_weather import MockLocation + +from tests.common import MockConfigEntry + + +async def test_async_setup_raises_entry_not_ready(hass): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + + with patch( + "pyipma.location.Location.get", side_effect=IPMAException("API unavailable") + ): + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"}, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_config_entry(hass): + """Test entry unloading.""" + + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + config_entry = MockConfigEntry( + domain="ipma", + data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index e129216730d869..62450871ee8fbf 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -1,6 +1,5 @@ """The tests for the IPMA weather component.""" -from collections import namedtuple -from datetime import datetime, timezone +from datetime import datetime from unittest.mock import patch from freezegun import freeze_time @@ -22,6 +21,8 @@ ) from homeassistant.const import STATE_UNKNOWN +from . import MockLocation + from tests.common import MockConfigEntry TEST_CONFIG = { @@ -39,128 +40,6 @@ } -class MockLocation: - """Mock Location from pyipma.""" - - async def observation(self, api): - """Mock Observation.""" - Observation = namedtuple( - "Observation", - [ - "accumulated_precipitation", - "humidity", - "pressure", - "radiation", - "temperature", - "wind_direction", - "wind_intensity_km", - ], - ) - - return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) - - async def forecast(self, api, period): - """Mock Forecast.""" - Forecast = namedtuple( - "Forecast", - [ - "feels_like_temperature", - "forecast_date", - "forecasted_hours", - "humidity", - "max_temperature", - "min_temperature", - "precipitation_probability", - "temperature", - "update_date", - "weather_type", - "wind_direction", - "wind_strength", - ], - ) - - WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) - - if period == 24: - return [ - Forecast( - None, - datetime(2020, 1, 16, 0, 0, 0), - 24, - None, - 16.2, - 10.6, - "100.0", - 13.4, - "2020-01-15T07:51:00", - WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), - "S", - "10", - ), - ] - if period == 1: - return [ - Forecast( - "7.7", - datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), - "S", - "32.7", - ), - Forecast( - "5.7", - datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(1, "Clear sky", "C\u00e9u limpo"), - "S", - "32.7", - ), - ] - - @property - def name(self): - """Mock location.""" - return "HomeTown" - - @property - def station(self): - """Mock station.""" - return "HomeTown Station" - - @property - def station_latitude(self): - """Mock latitude.""" - return 0 - - @property - def global_id_local(self): - """Mock global identifier of the location.""" - return 1130600 - - @property - def id_station(self): - """Mock identifier of the station.""" - return 1200545 - - @property - def station_longitude(self): - """Mock longitude.""" - return 0 - - class MockBadLocation(MockLocation): """Mock Location with unresponsive api.""" diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index 5b6a76e7e5723c..b6ac172488552e 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -11,9 +11,9 @@ @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_ZIP_CODE], data=config) entry.add_to_hass(hass) return entry @@ -26,43 +26,43 @@ def config_fixture(hass): } -@pytest.fixture(name="data_allergy_forecast", scope="session") +@pytest.fixture(name="data_allergy_forecast", scope="package") def data_allergy_forecast_fixture(): """Define allergy forecast data.""" return json.loads(load_fixture("allergy_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_allergy_index", scope="session") +@pytest.fixture(name="data_allergy_index", scope="package") def data_allergy_index_fixture(): """Define allergy index data.""" return json.loads(load_fixture("allergy_index_data.json", "iqvia")) -@pytest.fixture(name="data_allergy_outlook", scope="session") +@pytest.fixture(name="data_allergy_outlook", scope="package") def data_allergy_outlook_fixture(): """Define allergy outlook data.""" return json.loads(load_fixture("allergy_outlook_data.json", "iqvia")) -@pytest.fixture(name="data_asthma_forecast", scope="session") +@pytest.fixture(name="data_asthma_forecast", scope="package") def data_asthma_forecast_fixture(): """Define asthma forecast data.""" return json.loads(load_fixture("asthma_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_asthma_index", scope="session") +@pytest.fixture(name="data_asthma_index", scope="package") def data_asthma_index_fixture(): """Define asthma index data.""" return json.loads(load_fixture("asthma_index_data.json", "iqvia")) -@pytest.fixture(name="data_disease_forecast", scope="session") +@pytest.fixture(name="data_disease_forecast", scope="package") def data_disease_forecast_fixture(): """Define disease forecast data.""" return json.loads(load_fixture("disease_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_disease_index", scope="session") +@pytest.fixture(name="data_disease_index", scope="package") def data_disease_index_fixture(): """Define disease index data.""" return json.loads(load_fixture("disease_index_data.json", "iqvia")) @@ -101,9 +101,3 @@ async def setup_iqvia_fixture( assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "12345" diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 4c5f4bcac75ef8..ee3e6817fc15bb 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,4 +1,6 @@ """Test IQVIA diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -6,19 +8,26 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", - "data": { - "zip_code": "12345", - }, + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "iqvia", + "title": REDACTED, + "data": {"zip_code": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "allergy_average_forecasted": { "Type": "pollen", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ {"Period": "2018-06-12T13:47:12.897", "Index": 6.6}, {"Period": "2018-06-13T13:47:12.897", "Index": 6.3}, @@ -26,16 +35,16 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): {"Period": "2018-06-15T13:47:12.897", "Index": 7.6}, {"Period": "2018-06-16T13:47:12.897", "Index": 7.3}, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "allergy_index": { "Type": "pollen", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Triggers": [ @@ -113,12 +122,12 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Index": 6.3, }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "allergy_outlook": { - "Market": "SCHENECTADY, CO", - "ZIP": "12345", + "Market": REDACTED, + "ZIP": REDACTED, "TrendID": 4, "Trend": "subsiding", "Outlook": "The amount of pollen in the air for Wednesday...", @@ -128,9 +137,9 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Type": "asthma", "ForecastDate": "2018-10-28T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Period": "2018-10-28T05:45:01.45", @@ -154,16 +163,16 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Idx": "5.5", }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "asthma_index": { "Type": "asthma", "ForecastDate": "2018-10-29T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Triggers": [ @@ -225,32 +234,32 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Idx": "4.6", }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "disease_average_forecasted": { "Type": "cold", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ {"Period": "2018-06-12T05:13:51.817", "Index": 2.4}, {"Period": "2018-06-13T05:13:51.817", "Index": 2.5}, {"Period": "2018-06-14T05:13:51.817", "Index": 2.5}, {"Period": "2018-06-15T05:13:51.817", "Index": 2.5}, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "disease_index": { "ForecastDate": "2019-04-07T00:00:00-04:00", "Location": { - "City": "SCHENECTADY", - "DisplayLocation": "Schenectady, NY", - "State": "NY", - "ZIP": "12345", + "City": REDACTED, + "DisplayLocation": REDACTED, + "State": REDACTED, + "ZIP": REDACTED, "periods": [ { "Idx": "6.8", diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py index e5ff9ab32073d1..c1f7bbb2f35ae2 100644 --- a/tests/components/jellyfin/__init__.py +++ b/tests/components/jellyfin/__init__.py @@ -1 +1,17 @@ """Tests for the jellyfin integration.""" +import json +from typing import Any + +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + + +def load_json_fixture(filename: str) -> Any: + """Load JSON fixture on-demand.""" + return json.loads(load_fixture(f"jellyfin/{filename}")) + + +async def async_load_json_fixture(hass: HomeAssistant, filename: str) -> Any: + """Load JSON fixture on-demand asynchronously.""" + return await hass.async_add_executor_job(load_json_fixture, filename) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py new file mode 100644 index 00000000000000..423e4ad395069b --- /dev/null +++ b/tests/components/jellyfin/conftest.py @@ -0,0 +1,153 @@ +"""Fixtures for Jellyfin integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch + +from jellyfin_apiclient_python import JellyfinClient +from jellyfin_apiclient_python.api import API +from jellyfin_apiclient_python.configuration import Config +from jellyfin_apiclient_python.connection_manager import ConnectionManager +import pytest + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import load_json_fixture +from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Jellyfin", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id="USER-UUID", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.jellyfin.async_setup_entry", return_value=True + ) as setup_mock: + yield setup_mock + + +@pytest.fixture +def mock_client_device_id() -> Generator[None, MagicMock, None]: + """Mock generating device id.""" + with patch( + "homeassistant.components.jellyfin.config_flow._generate_client_device_id" + ) as id_mock: + id_mock.return_value = "TEST-UUID" + yield id_mock + + +@pytest.fixture +def mock_auth() -> MagicMock: + """Return a mocked ConnectionManager.""" + jf_auth = create_autospec(ConnectionManager) + jf_auth.connect_to_address.return_value = load_json_fixture( + "auth-connect-address.json" + ) + jf_auth.login.return_value = load_json_fixture("auth-login.json") + + return jf_auth + + +@pytest.fixture +def mock_api() -> MagicMock: + """Return a mocked API.""" + jf_api = create_autospec(API) + jf_api.get_user_settings.return_value = load_json_fixture("get-user-settings.json") + jf_api.sessions.return_value = load_json_fixture("sessions.json") + + jf_api.artwork.side_effect = api_artwork_side_effect + jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.get_item.side_effect = api_get_item_side_effect + jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") + jf_api.user_items.side_effect = api_user_items_side_effect + + return jf_api + + +@pytest.fixture +def mock_config() -> MagicMock: + """Return a mocked JellyfinClient.""" + jf_config = create_autospec(Config) + jf_config.data = {} + + return jf_config + + +@pytest.fixture +def mock_client( + mock_config: MagicMock, mock_auth: MagicMock, mock_api: MagicMock +) -> MagicMock: + """Return a mocked JellyfinClient.""" + jf_client = create_autospec(JellyfinClient) + jf_client.auth = mock_auth + jf_client.config = mock_config + jf_client.jellyfin = mock_api + + return jf_client + + +@pytest.fixture +def mock_jellyfin(mock_client: MagicMock) -> Generator[None, MagicMock, None]: + """Return a mocked Jellyfin.""" + with patch( + "homeassistant.components.jellyfin.client_wrapper.Jellyfin", autospec=True + ) as jellyfin_mock: + jf = jellyfin_mock.return_value + jf.get_client.return_value = mock_client + + yield jf + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_jellyfin: MagicMock +) -> MockConfigEntry: + """Set up the Jellyfin integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +def api_artwork_side_effect(*args, **kwargs): + """Handle variable responses for artwork method.""" + item_id = args[0] + art = args[1] + ext = "jpg" + + return f"http://localhost/Items/{item_id}/Images/{art}.{ext}" + + +def api_get_item_side_effect(*args): + """Handle variable responses for get_item method.""" + return load_json_fixture("get-item-collection.json") + + +def api_user_items_side_effect(*args, **kwargs): + """Handle variable responses for items method.""" + params = kwargs.get("params", {}) if kwargs else {} + + if "parentId" in params: + return load_json_fixture("user-items-parent-id.json") + + return load_json_fixture("user-items.json") diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index b33f00818b733f..4953824a1c536f 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,16 +2,6 @@ from typing import Final -from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE - TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" - -MOCK_SUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["ServerSignIn"]} -MOCK_SUCCESFUL_LOGIN_RESPONSE: Final = {"AccessToken": "Test"} - -MOCK_UNSUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["Unavailable"]} -MOCK_UNSUCCESFUL_LOGIN_RESPONSE: Final = {""} - -MOCK_USER_SETTINGS: Final = {"Id": "123"} diff --git a/tests/components/jellyfin/fixtures/auth-connect-address-failure.json b/tests/components/jellyfin/fixtures/auth-connect-address-failure.json new file mode 100644 index 00000000000000..9055c2c7105d58 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-connect-address-failure.json @@ -0,0 +1,3 @@ +{ + "State": 0 +} diff --git a/tests/components/jellyfin/fixtures/auth-connect-address.json b/tests/components/jellyfin/fixtures/auth-connect-address.json new file mode 100644 index 00000000000000..2adfded30708c3 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-connect-address.json @@ -0,0 +1,4 @@ +{ + "State": 2, + "Servers": [{ "Id": "SERVER-UUID", "Name": "JELLYFIN-SERVER" }] +} diff --git a/tests/components/jellyfin/fixtures/auth-login-failure.json b/tests/components/jellyfin/fixtures/auth-login-failure.json new file mode 100644 index 00000000000000..0967ef424bce67 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-login-failure.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/jellyfin/fixtures/auth-login.json b/tests/components/jellyfin/fixtures/auth-login.json new file mode 100644 index 00000000000000..5df9dd599a8e43 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-login.json @@ -0,0 +1,1844 @@ +{ + "User": { + "Name": "string", + "ServerId": "string", + "ServerName": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PrimaryImageTag": "string", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": true, + "EnableAutoLogin": true, + "LastLoginDate": "2019-08-24T14:15:22Z", + "LastActivityDate": "2019-08-24T14:15:22Z", + "Configuration": { + "AudioLanguagePreference": "string", + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "string", + "DisplayMissingEpisodes": true, + "GroupedFolders": ["string"], + "SubtitleMode": "Default", + "DisplayCollectionsView": true, + "EnableLocalPassword": true, + "OrderedViews": ["string"], + "LatestItemsExcludes": ["string"], + "MyMediaExcludes": ["string"], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true + }, + "Policy": { + "IsAdministrator": true, + "IsHidden": true, + "IsDisabled": true, + "MaxParentalRating": 0, + "BlockedTags": ["string"], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [ + { + "Id": 0, + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "DayOfWeek": "Sunday", + "StartHour": 0, + "EndHour": 0 + } + ], + "BlockUnratedItems": ["Movie"], + "EnableRemoteControlOfOtherUsers": true, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": true, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": true, + "EnableContentDeletion": true, + "EnableContentDeletionFromFolders": ["string"], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": ["string"], + "EnableAllDevices": true, + "EnabledChannels": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "EnableAllChannels": true, + "EnabledFolders": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "EnableAllFolders": true, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": 0, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "BlockedChannels": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "string", + "PasswordResetProviderId": "string", + "SyncPlayAccess": "CreateAndJoinGroups" + }, + "PrimaryImageAspectRatio": 0 + }, + "SessionInfo": { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["string"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["string"], + "Id": "string", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "string", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "string", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "string", + "ApplicationVersion": "string", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "string", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + "AccessToken": "string", + "ServerId": "string" +} diff --git a/tests/components/jellyfin/fixtures/get-item-collection.json b/tests/components/jellyfin/fixtures/get-item-collection.json new file mode 100644 index 00000000000000..90ad63a39e4ce0 --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-item-collection.json @@ -0,0 +1,504 @@ +{ + "Name": "FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "CollectionFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} +} diff --git a/tests/components/jellyfin/fixtures/get-media-folders.json b/tests/components/jellyfin/fixtures/get-media-folders.json new file mode 100644 index 00000000000000..ff87751a9daa17 --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-media-folders.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "COLLECTION FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "COLLECTION-FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "CollectionFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "tvshows", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json new file mode 100644 index 00000000000000..5e28f87d8f208e --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -0,0 +1,19 @@ +{ + "Id": "string", + "ViewType": "string", + "SortBy": "string", + "IndexBy": "string", + "RememberIndexing": true, + "PrimaryImageHeight": 0, + "PrimaryImageWidth": 0, + "CustomPrefs": { + "property1": "string", + "property2": "string" + }, + "ScrollDirection": "Horizontal", + "ShowBackdrop": true, + "RememberSorting": true, + "SortOrder": "Ascending", + "ShowSidebar": true, + "Client": "emby" +} diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json new file mode 100644 index 00000000000000..00a1f5265db641 --- /dev/null +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -0,0 +1,4551 @@ +[ + { + "PlayState": { + "PositionTicks": 100000000, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 230000000, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 55, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-TWO", + "UserId": "USER-UUID-TWO", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-TWO", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "MOVIE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 2000000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "", + "Type": "Movie", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "Backdrop": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID-TWO", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": false, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": false, + "SupportsContentUploading": false, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": false, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-THREE", + "UserId": "USER-UUID", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-THREE", + "DeviceType": "string", + "DeviceId": "DEVICE-UUID-THREE", + "ApplicationVersion": "2.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.254", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FOUR", + "UserId": "USER-UUID-TWO", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FOUR", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FOUR", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + } +] diff --git a/tests/components/jellyfin/fixtures/user-items-parent-id.json b/tests/components/jellyfin/fixtures/user-items-parent-id.json new file mode 100644 index 00000000000000..2e06c30894c483 --- /dev/null +++ b/tests/components/jellyfin/fixtures/user-items-parent-id.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "FOLDER-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/user-items.json b/tests/components/jellyfin/fixtures/user-items.json new file mode 100644 index 00000000000000..7461626de18953 --- /dev/null +++ b/tests/components/jellyfin/fixtures/user-items.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index e898f8ac5cedd2..9dc0fc86b5e2a7 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -1,21 +1,13 @@ """Test the jellyfin config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant import config_entries, data_entry_flow -from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import ( - MOCK_SUCCESFUL_CONNECTION_STATE, - MOCK_SUCCESFUL_LOGIN_RESPONSE, - MOCK_UNSUCCESFUL_CONNECTION_STATE, - MOCK_UNSUCCESFUL_LOGIN_RESPONSE, - MOCK_USER_SETTINGS, - TEST_PASSWORD, - TEST_URL, - TEST_USERNAME, -) +from . import async_load_json_fixture +from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry @@ -31,52 +23,52 @@ async def test_abort_if_existing_entry(hass: HomeAssistant): assert result["reason"] == "single_instance_allowed" -async def test_form(hass: HomeAssistant): +async def test_form( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, + mock_setup_entry: MagicMock, +): """Test the complete configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_SUCCESFUL_CONNECTION_STATE, - ) as mock_connect, patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", - return_value=MOCK_SUCCESFUL_LOGIN_RESPONSE, - ) as mock_login, patch( - "homeassistant.components.jellyfin.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.jellyfin.client_wrapper.API.get_user_settings", - return_value=MOCK_USER_SETTINGS, - ) as mock_set_id: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == TEST_URL + assert result2["title"] == "JELLYFIN-SERVER" assert result2["data"] == { + CONF_CLIENT_DEVICE_ID: "TEST-UUID", CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } - assert len(mock_connect.mock_calls) == 1 - assert len(mock_login.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_set_id.mock_calls) == 1 + assert len(mock_client.jellyfin.get_user_settings.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant): +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): """Test we handle an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -84,27 +76,32 @@ async def test_form_cannot_connect(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_UNSUCCESFUL_CONNECTION_STATE, - ) as mock_connect: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_connect.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant): +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): """Test that we can handle invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -112,31 +109,30 @@ async def test_form_invalid_auth(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_SUCCESFUL_CONNECTION_STATE, - ) as mock_connect, patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", - return_value=MOCK_UNSUCCESFUL_LOGIN_RESPONSE, - ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} - assert len(mock_connect.mock_calls) == 1 - assert len(mock_login.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 -async def test_form_exception(hass: HomeAssistant): +async def test_form_exception( + hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock +): """Test we handle an unexpected exception during server setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -144,21 +140,75 @@ async def test_form_exception(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - side_effect=Exception("UnknownException"), - ) as mock_connect: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} - assert len(mock_connect.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + +async def test_form_persists_device_id_on_error( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): + """Test that we can handle invalid credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_client_device_id.return_value = "TEST-UUID-1" + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + mock_client_device_id.return_value = "TEST-UUID-2" + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login.json" + ) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result3 + assert result3["type"] == "create_entry" + assert result3["data"] == { + CONF_CLIENT_DEVICE_ID: "TEST-UUID-1", + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py new file mode 100644 index 00000000000000..b4c386be956dbc --- /dev/null +++ b/tests/components/jellyfin/test_diagnostics.py @@ -0,0 +1,611 @@ +"""Test Jellyfin diagnostics.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_client: ClientSession, +): + """Test generating diagnostics for a config entry.""" + entry = init_integration + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert diag + assert diag["entry"] == { + "title": "Jellyfin", + "data": { + "url": "https://example.com", + "username": "test-username", + "password": "**REDACTED**", + "client_device_id": entry.entry_id, + }, + } + assert diag["server"] == { + "id": "SERVER-UUID", + "name": "JELLYFIN-SERVER", + "version": None, + } + assert diag["sessions"] + assert len(diag["sessions"]) == 4 + assert diag["sessions"][0] == { + "id": "SESSION-UUID", + "user_id": "08ba1929-681e-4b24-929b-9245852f65c0", + "device_id": "DEVICE-UUID", + "device_name": "JELLYFIN-DEVICE", + "client_name": "Jellyfin for Developers", + "client_version": "1.0.0", + "capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": True, + "SupportsContentUploading": True, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": True, + "SupportsSync": True, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + {"Name": "string", "Value": "string", "Match": "Equals"} + ], + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": False, + "EnableSingleAlbumArtLimit": False, + "EnableSingleSubtitleLimit": False, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": False, + "RequiresPlainFolders": False, + "EnableMSMediaReceiverRegistrar": False, + "IgnoreTranscodeByteRangeRequests": False, + "XmlRootAttributes": [{"Name": "string", "Value": "string"}], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": False, + "EnableMpegtsM2TsMode": False, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": False, + "Context": "Streaming", + "EnableSubtitlesInManifest": False, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": False, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "Container": "string", + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "Codec": "string", + "Container": "string", + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string", + } + ], + }, + "AppStoreUrl": "string", + "IconUrl": "string", + }, + "now_playing": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": True, + "CanDownload": True, + "HasSubtitles": True, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": True, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [{"Name": "string", "Url": "string"}], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": True, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": True, + "IgnoreDts": True, + "IgnoreIndex": True, + "GenPtsInput": True, + "SupportsTranscoding": True, + "SupportsDirectStream": True, + "SupportsDirectPlay": True, + "IsInfiniteStream": True, + "RequiresOpening": True, + "OpenToken": "string", + "RequiresClosing": True, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": True, + "SupportsProbing": True, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": True, + "IsAVC": True, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": True, + "IsForced": True, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": True, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": True, + "IsTextSubtitleStream": True, + "SupportsExternalStream": True, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": True, + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string", + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string", + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0, + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": True, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": True, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [{"Url": "string", "Name": "string"}], + "ProviderIds": {"property1": "string", "property2": "string"}, + "IsHD": True, + "IsFolder": False, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": {"property1": "string", "property2": "string"}, + "Art": {"property1": "string", "property2": "string"}, + "Backdrop": {"property1": "string", "property2": "string"}, + "Banner": {"property1": "string", "property2": "string"}, + "Logo": {"property1": "string", "property2": "string"}, + "Thumb": {"property1": "string", "property2": "string"}, + "Disc": {"property1": "string", "property2": "string"}, + "Box": {"property1": "string", "property2": "string"}, + "Screenshot": {"property1": "string", "property2": "string"}, + "Menu": {"property1": "string", "property2": "string"}, + "Chapter": {"property1": "string", "property2": "string"}, + "BoxRear": {"property1": "string", "property2": "string"}, + "Profile": {"property1": "string", "property2": "string"}, + }, + } + ], + "Studios": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "GenreItems": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": True, + "Likes": True, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": True, + "Key": "string", + "ItemId": "string", + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": True, + "IsAVC": True, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": True, + "IsForced": True, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": True, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": True, + "IsTextSubtitleStream": True, + "SupportsExternalStream": True, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": True, + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": {"property1": "string", "property2": "string"}, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": {"property1": "string", "property2": "string"}, + "Art": {"property1": "string", "property2": "string"}, + "Backdrop": {"property1": "string", "property2": "string"}, + "Banner": {"property1": "string", "property2": "string"}, + "Logo": {"property1": "string", "property2": "string"}, + "Thumb": {"property1": "string", "property2": "string"}, + "Disc": {"property1": "string", "property2": "string"}, + "Box": {"property1": "string", "property2": "string"}, + "Screenshot": {"property1": "string", "property2": "string"}, + "Menu": {"property1": "string", "property2": "string"}, + "Chapter": {"property1": "string", "property2": "string"}, + "BoxRear": {"property1": "string", "property2": "string"}, + "Profile": {"property1": "string", "property2": "string"}, + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string", + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": True, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": True, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": True, + "IsSports": True, + "IsSeries": True, + "IsLive": True, + "IsNews": True, + "IsKids": True, + "IsPremiere": True, + "TimerId": "string", + "CurrentProgram": {}, + }, + "play_state": { + "PositionTicks": 100000000, + "CanSeek": True, + "IsPaused": True, + "IsMuted": True, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string", + }, + } diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py new file mode 100644 index 00000000000000..542be0736c7881 --- /dev/null +++ b/tests/components/jellyfin/test_init.py @@ -0,0 +1,48 @@ +"""Tests for the Jellyfin integration.""" +from unittest.mock import MagicMock + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_load_json_fixture + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the Jellyfin configuration entry not ready.""" + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, +) -> None: + """Test the Jellyfin configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py new file mode 100644 index 00000000000000..40ddfefce391aa --- /dev/null +++ b/tests/components/jellyfin/test_media_player.py @@ -0,0 +1,356 @@ +"""Tests for the Jellyfin media_player platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from aiohttp import ClientSession + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.media_player import ( + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MP_DOMAIN, + MediaClass, + MediaPlayerState, + MediaType, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_media_player( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test the Jellyfin media player.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("media_player.jellyfin_device") + + assert state + assert state.state == MediaPlayerState.PAUSED + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-DEVICE" + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True + assert state.attributes.get(ATTR_MEDIA_DURATION) == 60 + assert state.attributes.get(ATTR_MEDIA_POSITION) == 10 + assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "EPISODE-UUID" + assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MediaType.TVSHOW + assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == "SERIES" + assert state.attributes.get(ATTR_MEDIA_SEASON) == 1 + assert state.attributes.get(ATTR_MEDIA_EPISODE) == 3 + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-SESSION-UUID" + + assert len(mock_api.sessions.mock_calls) == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 2 + + mock_api.sessions.return_value = [] + async_fire_time_changed(hass, utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 3 + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == set() + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "DEVICE-UUID")} + assert device.manufacturer == "Jellyfin" + assert device.name == "JELLYFIN-DEVICE" + assert device.sw_version == "1.0.0" + + +async def test_media_player_music( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test the Jellyfin media player.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("media_player.jellyfin_device_four") + + assert state + assert state.state == MediaPlayerState.PLAYING + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN DEVICE FOUR" + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 1.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is False + assert state.attributes.get(ATTR_MEDIA_DURATION) == 73 + assert state.attributes.get(ATTR_MEDIA_POSITION) == 22 + assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "MUSIC-UUID" + assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MediaType.MUSIC + assert state.attributes.get(ATTR_MEDIA_ALBUM_NAME) == "ALBUM" + assert state.attributes.get(ATTR_MEDIA_ALBUM_ARTIST) == "Album Artist" + assert state.attributes.get(ATTR_MEDIA_ARTIST) == "Contributing Artist" + assert state.attributes.get(ATTR_MEDIA_TRACK) == 1 + assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None + assert state.attributes.get(ATTR_MEDIA_SEASON) is None + assert state.attributes.get(ATTR_MEDIA_EPISODE) is None + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id is None + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-SESSION-UUID-FOUR" + + +async def test_services( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin media player services.""" + state = hass.states.get("media_player.jellyfin_device") + assert state + + await hass.services.async_call( + MP_DOMAIN, + "play_media", + { + ATTR_ENTITY_ID: state.entity_id, + "media_content_type": "", + "media_content_id": "ITEM-UUID", + }, + blocking=True, + ) + assert len(mock_api.remote_play_media.mock_calls) == 1 + assert mock_api.remote_play_media.mock_calls[0].args == ( + "SESSION-UUID", + ["ITEM-UUID"], + ) + + await hass.services.async_call( + MP_DOMAIN, + "media_pause", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_pause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_play", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_unpause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_play_pause", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_playpause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_seek", + { + ATTR_ENTITY_ID: state.entity_id, + "seek_position": 10, + }, + blocking=True, + ) + assert len(mock_api.remote_seek.mock_calls) == 1 + assert mock_api.remote_seek.mock_calls[0].args == ( + "SESSION-UUID", + 100000000, + ) + + await hass.services.async_call( + MP_DOMAIN, + "media_stop", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_stop.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_set", + { + ATTR_ENTITY_ID: state.entity_id, + "volume_level": 0.5, + }, + blocking=True, + ) + assert len(mock_api.remote_set_volume.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_mute", + { + ATTR_ENTITY_ID: state.entity_id, + "is_volume_muted": True, + }, + blocking=True, + ) + assert len(mock_api.remote_mute.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_mute", + { + ATTR_ENTITY_ID: state.entity_id, + "is_volume_muted": False, + }, + blocking=True, + ) + assert len(mock_api.remote_unmute.mock_calls) == 1 + + +async def test_browse_media( + hass: HomeAssistant, + hass_ws_client: ClientSession, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + } + ) + response = await client.receive_json() + assert response["success"] + expected_child_item = { + "title": "COLLECTION FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + "can_play": False, + "can_expand": True, + "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", + "children_media_class": None, + } + + assert response["result"]["media_content_id"] == "" + assert response["result"]["media_content_type"] == "root" + assert response["result"]["title"] == "Jellyfin" + assert response["result"]["children"][0] == expected_child_item + + # browse collection folder + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + } + ) + + response = await client.receive_json() + expected_child_item = { + "title": "EPISODE", + "media_class": MediaClass.EPISODE.value, + "media_content_type": MediaType.EPISODE.value, + "media_content_id": "EPISODE-UUID", + "can_play": True, + "can_expand": False, + "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", + "children_media_class": None, + } + + assert response["success"] + assert response["result"]["media_content_id"] == "COLLECTION-FOLDER-UUID" + assert response["result"]["title"] == "FOLDER" + assert response["result"]["children"][0] == expected_child_item + + # browse for collection without children + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = {} + + await client.send_json( + { + "id": 3, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + } + ) + + response = await client.receive_json() + assert response["success"] is False + assert response["error"] + assert ( + response["error"]["message"] + == "Media not found: collection / COLLECTION-FOLDER-UUID" + ) + + # browse for non-existent item + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = {} + + await client.send_json( + { + "id": 4, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-UUID-404", + } + ) + + response = await client.receive_json() + assert response["success"] is False + assert response["error"] + assert ( + response["error"]["message"] + == "Media not found: collection / COLLECTION-UUID-404" + ) diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py new file mode 100644 index 00000000000000..087be30b70c942 --- /dev/null +++ b/tests/components/jellyfin/test_sensor.py @@ -0,0 +1,51 @@ +"""Tests for the Jellyfin sensor platform.""" +from unittest.mock import MagicMock + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_watching( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, +) -> None: + """Test the Jellyfin watching sensor.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.jellyfin_server") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER" + assert state.attributes.get(ATTR_ICON) == "mdi:television-play" + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching" + assert state.state == "3" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-watching" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == set() + assert device.entry_type is dr.DeviceEntryType.SERVICE + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SERVER-UUID")} + assert device.manufacturer == "Jellyfin" + assert device.name == "JELLYFIN-SERVER" + assert device.sw_version is None diff --git a/tests/components/keymitt_ble/__init__.py b/tests/components/keymitt_ble/__init__.py index 7ae4c20c406525..136ca99c56dc02 100644 --- a/tests/components/keymitt_ble/__init__.py +++ b/tests/components/keymitt_ble/__init__.py @@ -2,11 +2,12 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS +from tests.components.bluetooth import generate_advertisement_data + DOMAIN = "keymitt_ble" ENTRY_CONFIG = { @@ -38,7 +39,7 @@ def patch_async_setup_entry(return_value=True): service_data={}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="mibp", manufacturer_data={}, service_uuids=["0000abcd-0000-1000-8000-00805f9b34fb"], diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index e91cbaca8916a8..03268200077ea0 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -1,48 +1,38 @@ """The tests for Kira sensor platform.""" -import unittest from unittest.mock import MagicMock from homeassistant.components.kira import remote as kira -from tests.common import get_test_home_assistant - SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} +DEVICES = [] -class TestKiraSensor(unittest.TestCase): - """Tests the Kira Sensor platform.""" - # pylint: disable=invalid-name - DEVICES = [] +def add_entities(devices): + """Mock add devices.""" + for device in devices: + DEVICES.append(device) - def add_entities(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - self.mock_kira = MagicMock() - self.hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}} - self.hass.data[kira.DOMAIN][kira.CONF_REMOTE]["kira"] = self.mock_kira - self.addCleanup(self.hass.stop) +def test_service_call(hass): + """Test Kira's ability to send commands.""" + mock_kira = MagicMock() + hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}} + hass.data[kira.DOMAIN][kira.CONF_REMOTE]["kira"] = mock_kira - def test_service_call(self): - """Test Kira's ability to send commands.""" - kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities, DISCOVERY_INFO) - assert len(self.DEVICES) == 1 - remote = self.DEVICES[0] + kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) + assert len(DEVICES) == 1 + remote = DEVICES[0] - assert remote.name == "kira" + assert remote.name == "kira" - command = ["FAKE_COMMAND"] - device = "FAKE_DEVICE" - commandTuple = (command[0], device) - remote.send_command(device=device, command=command) + command = ["FAKE_COMMAND"] + device = "FAKE_DEVICE" + commandTuple = (command[0], device) + remote.send_command(device=device, command=command) - self.mock_kira.sendCode.assert_called_with(commandTuple) + mock_kira.sendCode.assert_called_with(commandTuple) diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index b835a25ae9095b..f0c771fbda0540 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -1,50 +1,42 @@ """The tests for Kira sensor platform.""" -import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from homeassistant.components.kira import sensor as kira -from tests.common import get_test_home_assistant - TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} +DEVICES = [] + -class TestKiraSensor(unittest.TestCase): - """Tests the Kira Sensor platform.""" +def add_entities(devices): + """Mock add devices.""" + for device in devices: + DEVICES.append(device) - # pylint: disable=invalid-name - DEVICES = [] - def add_entities(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) +@patch("homeassistant.components.kira.sensor.KiraReceiver.schedule_update_ha_state") +def test_kira_sensor_callback(mock_schedule_update_ha_state, hass): + """Ensure Kira sensor properly updates its attributes from callback.""" + mock_kira = MagicMock() + hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}} + hass.data[kira.DOMAIN][kira.CONF_SENSOR]["kira"] = mock_kira - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - mock_kira = MagicMock() - self.hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}} - self.hass.data[kira.DOMAIN][kira.CONF_SENSOR]["kira"] = mock_kira - self.addCleanup(self.hass.stop) + kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) + assert len(DEVICES) == 1 + sensor = DEVICES[0] - # pylint: disable=protected-access - def test_kira_sensor_callback(self): - """Ensure Kira sensor properly updates its attributes from callback.""" - kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities, DISCOVERY_INFO) - assert len(self.DEVICES) == 1 - sensor = self.DEVICES[0] + assert sensor.name == "kira" - assert sensor.name == "kira" + sensor.hass = hass - sensor.hass = self.hass + codeName = "FAKE_CODE" + deviceName = "FAKE_DEVICE" + codeTuple = (codeName, deviceName) + sensor._update_callback(codeTuple) - codeName = "FAKE_CODE" - deviceName = "FAKE_DEVICE" - codeTuple = (codeName, deviceName) - sensor._update_callback(codeTuple) + mock_schedule_update_ha_state.assert_called - assert sensor.state == codeName - assert sensor.extra_state_attributes == {kira.CONF_DEVICE: deviceName} + assert sensor.state == codeName + assert sensor.extra_state_attributes == {kira.CONF_DEVICE: deviceName} diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 56cf5b2c00a6c5..2f7484fad8b117 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ColorMode, @@ -166,19 +166,25 @@ async def test_light_color_temp_absolute(hass: HomeAssistant, knx: KNXTestKit): brightness=255, color_mode=ColorMode.COLOR_TEMP, color_temp=370, + color_temp_kelvin=2700, ) # change color temperature from HA await hass.services.async_call( "light", "turn_on", - {"entity_id": "light.test", ATTR_COLOR_TEMP: 250}, # 4000 Kelvin - 0x0FA0 + {"entity_id": "light.test", ATTR_COLOR_TEMP_KELVIN: 4000}, # 4000 - 0x0FA0 blocking=True, ) await knx.assert_write(test_ct, (0x0F, 0xA0)) knx.assert_state("light.test", STATE_ON, color_temp=250) # change color temperature from KNX await knx.receive_write(test_ct_state, (0x17, 0x70)) # 6000 Kelvin - 166 Mired - knx.assert_state("light.test", STATE_ON, color_temp=166) + knx.assert_state( + "light.test", + STATE_ON, + color_temp=166, + color_temp_kelvin=6000, + ) async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit): @@ -222,19 +228,33 @@ async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit): brightness=255, color_mode=ColorMode.COLOR_TEMP, color_temp=250, + color_temp_kelvin=4000, ) # change color temperature from HA await hass.services.async_call( "light", "turn_on", - {"entity_id": "light.test", ATTR_COLOR_TEMP: 300}, # 3333 Kelvin - 33 % - 0x54 + { + "entity_id": "light.test", + ATTR_COLOR_TEMP_KELVIN: 3333, # 3333 Kelvin - 33.3 % - 0x55 + }, blocking=True, ) - await knx.assert_write(test_ct, (0x54,)) - knx.assert_state("light.test", STATE_ON, color_temp=300) + await knx.assert_write(test_ct, (0x55,)) + knx.assert_state( + "light.test", + STATE_ON, + color_temp=300, + color_temp_kelvin=3333, + ) # change color temperature from KNX - await knx.receive_write(test_ct_state, (0xE6,)) # 3900 Kelvin - 90 % - 256 Mired - knx.assert_state("light.test", STATE_ON, color_temp=256) + await knx.receive_write(test_ct_state, (0xE6,)) # 3901 Kelvin - 90.1 % - 256 Mired + knx.assert_state( + "light.test", + STATE_ON, + color_temp=256, + color_temp_kelvin=3901, + ) async def test_light_hs_color(hass: HomeAssistant, knx: KNXTestKit): diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cd55c9914f5ca8..db1f0c57929d75 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -1,12 +1,19 @@ """Tests for the LaMetric button platform.""" from unittest.mock import MagicMock +from demetriek import LaMetricConnectionError, LaMetricError import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.lametric.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -111,3 +118,156 @@ async def test_button_app_previous( state = hass.states.get("button.frenck_s_lametric_previous_app") assert state assert state.state == "2022-09-19T12:07:30+00:00" + + +@pytest.mark.freeze_time("2022-10-19 12:44:00") +async def test_button_dismiss_current_notification( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric dismiss current notification button.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get( + "button.frenck_s_lametric_dismiss_current_notification" + ) + assert entry + assert entry.unique_id == "SA110405124500W00BS9-dismiss_current" + assert entry.entity_category == EntityCategory.CONFIG + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device_entry.manufacturer == "LaMetric Inc." + assert device_entry.model == "LM 37X8" + assert device_entry.name == "Frenck's LaMetric" + assert device_entry.sw_version == "2.2.2" + assert device_entry.hw_version is None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_dismiss_current_notification"}, + blocking=True, + ) + + assert len(mock_lametric.dismiss_current_notification.mock_calls) == 1 + mock_lametric.dismiss_current_notification.assert_called_with() + + state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") + assert state + assert state.state == "2022-10-19T12:44:00+00:00" + + +@pytest.mark.freeze_time("2022-10-19 12:44:00") +async def test_button_dismiss_all_notifications( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric dismiss all notifications button.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get( + "button.frenck_s_lametric_dismiss_all_notifications" + ) + assert entry + assert entry.unique_id == "SA110405124500W00BS9-dismiss_all" + assert entry.entity_category == EntityCategory.CONFIG + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device_entry.manufacturer == "LaMetric Inc." + assert device_entry.model == "LM 37X8" + assert device_entry.name == "Frenck's LaMetric" + assert device_entry.sw_version == "2.2.2" + assert device_entry.hw_version is None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_dismiss_all_notifications"}, + blocking=True, + ) + + assert len(mock_lametric.dismiss_all_notifications.mock_calls) == 1 + mock_lametric.dismiss_all_notifications.assert_called_with() + + state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") + assert state + assert state.state == "2022-10-19T12:44:00+00:00" + + +@pytest.mark.freeze_time("2022-10-11 22:00:00") +async def test_button_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric buttons.""" + mock_lametric.app_next.side_effect = LaMetricError + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.frenck_s_lametric_next_app") + assert state + assert state.state == "2022-10-11T22:00:00+00:00" + + +async def test_button_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric buttons.""" + mock_lametric.app_next.side_effect = LaMetricConnectionError + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.frenck_s_lametric_next_app") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 338fe5052d1a3f..a23b50c9813788 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -18,7 +18,12 @@ ATTR_UPNP_SERIAL, SsdpServiceInfo, ) -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_SSDP, + SOURCE_USER, +) from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -743,3 +748,173 @@ async def test_dhcp_unknown_device( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "unknown" + + +async def test_reauth_cloud_import( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_setup_entry: MagicMock, + mock_lametric_cloud_config_flow: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow importing api keys from the cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "pick_implementation"} + ) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + aioclient_mock.post( + "https://developer.lametric.com/api/v2/oauth2/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(flow_id) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 + assert len(mock_lametric_config_flow.device.mock_calls) == 1 + assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + + +async def test_reauth_cloud_abort_device_not_found( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_setup_entry: MagicMock, + mock_lametric_cloud_config_flow: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow importing api keys from the cloud.""" + mock_config_entry.unique_id = "UKNOWN_DEVICE" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "pick_implementation"} + ) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + aioclient_mock.post( + "https://developer.lametric.com/api/v2/oauth2/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(flow_id) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_device_not_found" + + assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 + assert len(mock_lametric_config_flow.device.mock_calls) == 0 + assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + + +async def test_reauth_manual( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with manual entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "manual_entry"} + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_API_KEY: "mock-api-key"} + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric_config_flow.device.mock_calls) == 1 + assert len(mock_lametric_config_flow.notify.mock_calls) == 1 diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py new file mode 100644 index 00000000000000..27c031d19e434b --- /dev/null +++ b/tests/components/lametric/test_diagnostics.py @@ -0,0 +1,57 @@ +"""Tests for the diagnostics data provided by the LaMetric integration.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +) -> None: + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "device_id": REDACTED, + "name": REDACTED, + "serial_number": REDACTED, + "os_version": "2.2.2", + "mode": "auto", + "model": "LM 37X8", + "audio": { + "volume": 100, + "volume_range": {"range_min": 0, "range_max": 100}, + "volume_limit": {"range_min": 0, "range_max": 100}, + }, + "bluetooth": { + "available": True, + "name": REDACTED, + "active": False, + "discoverable": True, + "pairable": True, + "address": "AA:BB:CC:DD:EE:FF", + }, + "display": { + "brightness": 100, + "brightness_mode": "auto", + "width": 37, + "height": 8, + "display_type": "mixed", + }, + "wifi": { + "active": True, + "mac": "AA:BB:CC:DD:EE:FF", + "available": True, + "encryption": "WPA", + "ssid": REDACTED, + "ip": "127.0.0.1", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 21, + }, + } diff --git a/tests/components/lametric/test_helpers.py b/tests/components/lametric/test_helpers.py new file mode 100644 index 00000000000000..9a03a4d52cf3e2 --- /dev/null +++ b/tests/components/lametric/test_helpers.py @@ -0,0 +1,38 @@ +"""Tests for the LaMetric helpers.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lametric.helpers import async_get_coordinator_by_device_id +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_get_coordinator_by_device_id( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test get LaMetric coordinator by device ID .""" + entity_registry = er.async_get(hass) + + with pytest.raises(ValueError, match="Unknown LaMetric device ID: bla"): + async_get_coordinator_by_device_id(hass, "bla") + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + coordinator = async_get_coordinator_by_device_id(hass, entry.device_id) + assert coordinator.data == mock_lametric.device.return_value + + # Unload entry + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + ValueError, match=f"No coordinator for device ID: {entry.device_id}" + ): + async_get_coordinator_by_device_id(hass, entry.device_id) diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 965264e891705b..50695fc4e55bae 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -3,11 +3,15 @@ from unittest.mock import MagicMock from aiohttp import ClientWebSocketResponse -from demetriek import LaMetricConnectionError, LaMetricConnectionTimeoutError +from demetriek import ( + LaMetricAuthenticationError, + LaMetricConnectionError, + LaMetricConnectionTimeoutError, +) import pytest from homeassistant.components.lametric.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -70,3 +74,30 @@ async def test_yaml_config_raises_repairs( issues = await get_repairs(hass, hass_ws_client) assert len(issues) == 1 assert issues[0]["issue_id"] == "manual_migration" + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_lametric.device.side_effect = LaMetricAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "choice_enter_manual_or_fetch_cloud" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py new file mode 100644 index 00000000000000..3b581c81e75b79 --- /dev/null +++ b/tests/components/lametric/test_notify.py @@ -0,0 +1,124 @@ +"""Tests for the LaMetric notify platform.""" +from unittest.mock import MagicMock + +from demetriek import ( + LaMetricError, + Notification, + NotificationIconType, + NotificationPriority, + NotificationSound, + NotificationSoundCategory, + Simple, +) +import pytest + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +NOTIFY_SERVICE = "frenck_s_lametric" + + +async def test_notification_defaults( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification defaults.""" + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "Try not to become a man of success. Rather become a man of value", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == "a7956" + assert ( + frame.text == "Try not to become a man of success. Rather become a man of value" + ) + + +async def test_notification_options( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification options.""" + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "The secret of getting ahead is getting started", + ATTR_DATA: { + "icon": "1234", + "sound": "positive1", + "cycles": 3, + "icon_type": "alert", + "priority": "critical", + }, + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.ALERT + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.POSITIVE1 + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == 1234 + assert frame.text == "The secret of getting ahead is getting started" + + +async def test_notification_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification error.""" + mock_lametric.notify.side_effect = LaMetricError + + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "It's failure that gives you the proper perspective on success", + }, + blocking=True, + ) diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py new file mode 100644 index 00000000000000..2ecfc43246eb4c --- /dev/null +++ b/tests/components/lametric/test_number.py @@ -0,0 +1,195 @@ +"""Tests for the LaMetric number platform.""" +from unittest.mock import MagicMock + +from demetriek import LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_brightness( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric display brightness controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("number.frenck_s_lametric_brightness") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" + assert state.attributes.get(ATTR_ICON) == "mdi:brightness-6" + assert state.attributes.get(ATTR_MAX) == 100 + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_STEP) == 1 + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.state == "100" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-brightness" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_brightness", + ATTR_VALUE: 21, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(mock_lametric.display.mock_calls) == 1 + mock_lametric.display.assert_called_once_with(brightness=21) + + +async def test_volume( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric volume controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Volume" + assert state.attributes.get(ATTR_ICON) == "mdi:volume-high" + assert state.attributes.get(ATTR_MAX) == 100 + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_STEP) == 1 + assert state.state == "100" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-volume" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(mock_lametric.audio.mock_calls) == 1 + mock_lametric.audio.assert_called_once_with(volume=42) + + +async def test_number_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric numbers.""" + mock_lametric.audio.side_effect = LaMetricError + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + +async def test_number_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric numbers.""" + mock_lametric.audio.side_effect = LaMetricConnectionError + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py new file mode 100644 index 00000000000000..65c1df2ab3dc42 --- /dev/null +++ b/tests/components/lametric/test_select.py @@ -0,0 +1,136 @@ +"""Tests for the LaMetric select platform.""" +from unittest.mock import MagicMock + +from demetriek import BrightnessMode, LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_OPTION, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_brightness_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric brightness mode controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness mode" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:brightness-auto" + assert state.attributes.get(ATTR_OPTIONS) == ["auto", "manual"] + assert state.state == BrightnessMode.AUTO + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-brightness_mode" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + + assert len(mock_lametric.display.mock_calls) == 1 + mock_lametric.display.assert_called_once_with(brightness_mode=BrightnessMode.MANUAL) + + +async def test_select_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric selects.""" + mock_lametric.display.side_effect = LaMetricError + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + +async def test_select_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric selects.""" + mock_lametric.display.side_effect = LaMetricConnectionError + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py new file mode 100644 index 00000000000000..76f584b1cde30a --- /dev/null +++ b/tests/components/lametric/test_sensor.py @@ -0,0 +1,54 @@ +"""Tests for the LaMetric sensor platform.""" +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_wifi_signal( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric Wi-Fi sensor.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.frenck_s_lametric_wi_fi_signal") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Wi-Fi signal" + assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.state == "21" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert entry.unique_id == "SA110405124500W00BS9-rssi" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py new file mode 100644 index 00000000000000..4792db266f88cb --- /dev/null +++ b/tests/components/lametric/test_services.py @@ -0,0 +1,211 @@ +"""Tests for the LaMetric services.""" +from unittest.mock import MagicMock + +from demetriek import ( + Chart, + LaMetricError, + Notification, + NotificationIconType, + NotificationPriority, + NotificationSound, + NotificationSoundCategory, + Simple, +) +import pytest + +from homeassistant.components.lametric.const import ( + CONF_CYCLES, + CONF_DATA, + CONF_ICON_TYPE, + CONF_MESSAGE, + CONF_PRIORITY, + CONF_SOUND, + DOMAIN, + SERVICE_CHART, + SERVICE_MESSAGE, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_ICON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_service_chart( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric chart service.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DEVICE_ID: entry.device_id, + CONF_DATA: [1, 2, 3, 4, 5, 4, 3, 2, 1], + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Chart + assert frame.data == [1, 2, 3, 4, 5, 4, 3, 2, 1] + + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DATA: [1, 2, 3, 4, 5, 4, 3, 2, 1], + CONF_DEVICE_ID: entry.device_id, + CONF_CYCLES: 3, + CONF_ICON_TYPE: "info", + CONF_PRIORITY: "critical", + CONF_SOUND: "cat", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 2 + + notification: Notification = mock_lametric.notify.mock_calls[1][2]["notification"] + assert notification.icon_type is NotificationIconType.INFO + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.CAT + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Chart + assert frame.data == [1, 2, 3, 4, 5, 4, 3, 2, 1] + + mock_lametric.notify.side_effect = LaMetricError + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DEVICE_ID: entry.device_id, + CONF_DATA: [1, 2, 3, 4, 5], + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 3 + + +async def test_service_message( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric message service.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Hi!", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon is None + assert frame.text == "Hi!" + + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Meow!", + CONF_CYCLES: 3, + CONF_ICON_TYPE: "info", + CONF_PRIORITY: "critical", + CONF_SOUND: "cat", + CONF_ICON: "6916", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 2 + + notification: Notification = mock_lametric.notify.mock_calls[1][2]["notification"] + assert notification.icon_type is NotificationIconType.INFO + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.CAT + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == 6916 + assert frame.text == "Meow!" + + mock_lametric.notify.side_effect = LaMetricError + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Epic failure!", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 3 diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py new file mode 100644 index 00000000000000..7ed47fe463efea --- /dev/null +++ b/tests/components/lametric/test_switch.py @@ -0,0 +1,153 @@ +"""Tests for the LaMetric switch platform.""" +from unittest.mock import MagicMock + +from demetriek import LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_OFF, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_bluetooth( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric Bluetooth control.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Bluetooth" + assert state.attributes.get(ATTR_ICON) == "mdi:bluetooth" + assert state.state == STATE_OFF + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-bluetooth" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + + assert len(mock_lametric.bluetooth.mock_calls) == 1 + mock_lametric.bluetooth.assert_called_once_with(active=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + + assert len(mock_lametric.bluetooth.mock_calls) == 2 + mock_lametric.bluetooth.assert_called_with(active=False) + + mock_lametric.device.return_value.bluetooth.available = False + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_switch_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric switches.""" + mock_lametric.bluetooth.side_effect = LaMetricError + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + +async def test_switch_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric switches.""" + mock_lametric.bluetooth.side_effect = LaMetricConnectionError + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/led_ble/__init__.py b/tests/components/led_ble/__init__.py index 702b793f57af58..7f48ff7a087654 100644 --- a/tests/components/led_ble/__init__.py +++ b/tests/components/led_ble/__init__.py @@ -1,9 +1,10 @@ """Tests for the LED BLE Bluetooth integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="Triones:F30200000152C", address="AA:BB:CC:DD:EE:FF", @@ -13,7 +14,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -27,7 +28,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -45,7 +46,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 72a355877e1fa2..774376e1a99af5 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -91,6 +91,7 @@ def _mocked_bulb() -> Light: bulb.set_power = MockLifxCommand(bulb) bulb.set_color = MockLifxCommand(bulb) bulb.get_hostfirmware = MockLifxCommand(bulb) + bulb.get_wifiinfo = MockLifxCommand(bulb, signal=100) bulb.get_version = MockLifxCommand(bulb) bulb.set_waveform_optional = MockLifxCommand(bulb) bulb.product = 1 # LIFX Original 1000 @@ -123,12 +124,15 @@ def _mocked_clean_bulb() -> Light: bulb = _mocked_bulb() bulb.get_hev_cycle = MockLifxCommand(bulb) bulb.set_hev_cycle = MockLifxCommand(bulb) + bulb.get_hev_configuration = MockLifxCommand(bulb) + bulb.get_last_hev_cycle_result = MockLifxCommand(bulb) bulb.hev_cycle_configuration = {"duration": 7200, "indication": False} bulb.hev_cycle = { "duration": 7200, "remaining": 30, "last_power": False, } + bulb.last_hev_cycle_result = 0 bulb.product = 90 return bulb @@ -151,7 +155,23 @@ def _mocked_light_strip() -> Light: bulb.set_color_zones = MockLifxCommand(bulb) bulb.get_multizone_effect = MockLifxCommand(bulb) bulb.set_multizone_effect = MockLifxCommand(bulb) + bulb.get_extended_color_zones = MockLifxCommand(bulb) + bulb.set_extended_color_zones = MockLifxCommand(bulb) + return bulb + +def _mocked_tile() -> Light: + bulb = _mocked_bulb() + bulb.product = 55 # LIFX Tile + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + return bulb + + +def _mocked_bulb_old_firmware() -> Light: + bulb = _mocked_bulb() + bulb.host_firmware_version = "2.77" return bulb diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index bb0b210704aecb..40db6ce1148fec 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Test the lifx binary sensor platwform.""" +"""Test the lifx binary sensor platform.""" from __future__ import annotations from datetime import timedelta diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py new file mode 100644 index 00000000000000..4eccd19634d5d9 --- /dev/null +++ b/tests/components/lifx/test_diagnostics.py @@ -0,0 +1,385 @@ +"""Test LIFX diagnostics.""" +from homeassistant.components import lifx +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + _mocked_bulb, + _mocked_clean_bulb, + _mocked_infrared_bulb, + _mocked_light_strip, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 2500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 1, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_clean_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_clean_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": True, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 1500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hev": { + "hev_config": {"duration": 7200, "indication": False}, + "hev_cycle": {"duration": 7200, "last_power": False, "remaining": 30}, + "last_result": 0, + }, + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 90, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_infrared_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_infrared_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": True, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 1500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "infrared": {"brightness": 65535}, + "kelvin": 4, + "power": 0, + "product_id": 29, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_legacy_multizone_bulb_diagnostics( + hass: HomeAssistant, hass_client +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.zones_count = 8 + bulb.color_zones = [ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 2500, + "multizone": True, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 31, + "saturation": 2, + "vendor": None, + "zones": { + "count": 8, + "state": { + "0": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "1": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "2": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "3": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "4": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "5": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "6": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "7": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + }, + }, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_multizone_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 + bulb.zones_count = 8 + bulb.color_zones = [ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": True, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_ext_mz_firmware": 1532997580, + "min_ext_mz_firmware_components": [2, 77], + "min_kelvin": 1500, + "multizone": True, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 38, + "saturation": 2, + "vendor": None, + "zones": { + "count": 8, + "state": { + "0": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "1": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "2": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "3": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "4": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "5": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "6": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "7": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + }, + }, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index e7c18989767a6b..6fe63b14b6a5f4 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -12,14 +12,18 @@ from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.lifx.manager import ( ATTR_DIRECTION, + ATTR_PALETTE, ATTR_SPEED, + ATTR_THEME, SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -54,6 +58,7 @@ _mocked_bulb_new_firmware, _mocked_clean_bulb, _mocked_light_strip, + _mocked_tile, _mocked_white_bulb, _patch_config_flow_try_connect, _patch_device, @@ -412,6 +417,383 @@ async def test_light_strip(hass: HomeAssistant) -> None: ) +async def test_extended_multizone_messages(hass: HomeAssistant) -> None: + """Test a light strip that supports extended multizone.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 # LIFX Beam + bulb.power_level = 65535 + bulb.color = [65535, 65535, 65535, 3500] + bulb.color_zones = [(65535, 65535, 65535, 3500)] * 8 + bulb.zones_count = 8 + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) + assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) + assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is False + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is True + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 10, 30)}, + blocking=True, + ) + # always use a set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.3, 0.7)}, + blocking=True, + ) + # Single color uses the fast path + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + # always use set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [0, 2], + }, + blocking=True, + ) + # set a two zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 255, 255), ATTR_ZONES: [3]}, + blocking=True, + ) + # set a one zone + assert len(bulb.set_power.calls) == 2 + assert len(bulb.get_color_zones.calls) == 0 + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + + bulb.get_color_zones.reset_mock() + bulb.set_power.reset_mock() + bulb.set_color_zones.reset_mock() + + bulb.set_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + bulb.set_extended_color_zones = MockLifxCommand(bulb) + bulb.get_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + +async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_tile() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_flame"}, + blocking=True, + ) + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 3, + "speed": 3, + "palette": [], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 4.0, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 4, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54613, 65535, 65535, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: """Test the firmware move effect on a light strip.""" config_entry = MockConfigEntry( @@ -419,6 +801,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() + bulb.product = 38 bulb.power_level = 0 bulb.color = [65535, 65535, 65535, 65535] with _patch_discovery(device=bulb), _patch_config_flow_try_connect( @@ -446,6 +829,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: "speed": 3.0, "direction": 0, } + bulb.get_multizone_effect.reset_mock() bulb.set_multizone_effect.reset_mock() bulb.set_power.reset_mock() @@ -454,12 +838,17 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_EFFECT_MOVE, - {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4.5, ATTR_DIRECTION: "left"}, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 4.5, + ATTR_DIRECTION: "left", + ATTR_THEME: "sports", + }, blocking=True, ) bulb.power_level = 65535 - bulb.effect = {"name": "effect_move", "enable": 1} + bulb.effect = {"name": "MOVE", "speed": 4.5, "direction": "Left"} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -467,6 +856,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_extended_color_zones.calls) == 1 assert len(bulb.set_multizone_effect.calls) == 1 call_dict = bulb.set_multizone_effect.calls[0][1] call_dict.pop("callb") @@ -547,9 +937,9 @@ async def test_color_light_with_temp( ColorMode.COLOR_TEMP, ColorMode.HS, ] - assert attributes[ATTR_HS_COLOR] == (31.007, 6.862) - assert attributes[ATTR_RGB_COLOR] == (255, 246, 237) - assert attributes[ATTR_XY_COLOR] == (0.339, 0.338) + assert attributes[ATTR_HS_COLOR] == (30.754, 7.122) + assert attributes[ATTR_RGB_COLOR] == (255, 246, 236) + assert attributes[ATTR_XY_COLOR] == (0.34, 0.339) bulb.color = [65535, 65535, 65535, 65535] await hass.services.async_call( @@ -674,7 +1064,7 @@ async def test_white_bulb(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ] - assert attributes[ATTR_COLOR_TEMP] == 166 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 6000 await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -775,10 +1165,10 @@ async def test_white_light_fails(hass): await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6000}, blocking=True, ) - assert bulb.set_color.calls[0][0][0] == [1, 0, 3, 6535] + assert bulb.set_color.calls[0][0][0] == [1, 0, 3, 6000] bulb.set_color.reset_mock() diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index bc2d6f0fc1e804..d190cbe6b10cde 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -17,6 +17,7 @@ SERIAL, MockLifxCommand, _mocked_infrared_bulb, + _mocked_light_strip, _patch_config_flow_try_connect, _patch_device, _patch_discovery, @@ -25,6 +26,43 @@ from tests.common import MockConfigEntry, async_fire_time_changed +async def test_theme_select(hass: HomeAssistant) -> None: + """Test selecting a theme.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 + bulb.power_level = 0 + bulb.color = [0, 0, 65535, 3500] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.my_bulb_theme" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert not entity.disabled + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: entity_id, "option": "intense"}, + blocking=True, + ) + + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_extended_color_zones.reset_mock() + + async def test_infrared_brightness(hass: HomeAssistant) -> None: """Test getting and setting infrared brightness.""" diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py new file mode 100644 index 00000000000000..a36e151849b472 --- /dev/null +++ b/tests/components/lifx/test_sensor.py @@ -0,0 +1,131 @@ +"""Test the LIFX sensor platform.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components import lifx +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONF_HOST, + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + _mocked_bulb, + _mocked_bulb_old_firmware, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_rssi_sensor(hass: HomeAssistant) -> None: + """Test LIFX RSSI sensor entity.""" + + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_bulb_rssi" + entity_registry = er.async_get(hass) + + entry = entity_registry.entities.get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = entity_registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) + await hass.async_block_till_done() + + rssi = hass.states.get(entity_id) + assert ( + rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT + + +async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: + """Test LIFX RSSI sensor entity.""" + + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_old_firmware() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_bulb_rssi" + entity_registry = er.async_get(hass) + + entry = entity_registry.entities.get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = entity_registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) + await hass.async_block_till_done() + + rssi = hass.states.get(entity_id) + assert rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS + assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 1f21981340f954..bff46af29e926d 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -629,11 +629,33 @@ async def test_default_profiles_group( }, { light.ATTR_COLOR_TEMP: 600, + light.ATTR_COLOR_TEMP_KELVIN: 1666, light.ATTR_BRIGHTNESS: 11, light.ATTR_TRANSITION: 1, }, { light.ATTR_COLOR_TEMP: 600, + light.ATTR_COLOR_TEMP_KELVIN: 1666, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + ), + ( + # Color temp in turn on params, color from profile ignored + { + light.ATTR_COLOR_TEMP_KELVIN: 6500, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + { + light.ATTR_COLOR_TEMP: 153, + light.ATTR_COLOR_TEMP_KELVIN: 6500, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + { + light.ATTR_COLOR_TEMP: 153, + light.ATTR_COLOR_TEMP_KELVIN: 6500, light.ATTR_BRIGHTNESS: 11, light.ATTR_TRANSITION: 1, }, @@ -1171,7 +1193,7 @@ async def test_light_backwards_compatibility_color_mode( entity2 = platform.ENTITIES[2] entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP - entity2.color_temp = 100 + entity2.color_temp_kelvin = 10000 entity3 = platform.ENTITIES[3] entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR @@ -1182,7 +1204,7 @@ async def test_light_backwards_compatibility_color_mode( light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP ) entity4.hs_color = (240, 100) - entity4.color_temp = 100 + entity4.color_temp_kelvin = 10000 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1440,7 +1462,7 @@ async def test_light_service_call_color_conversion(hass, enable_custom_integrati _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} _, data = entity6.last_call("turn_on") - # The midpoint the the white channels is warm, compensated by adding green + blue + # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} await hass.services.async_call( @@ -1843,7 +1865,7 @@ async def test_light_service_call_color_temp_emulation( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 200} + assert data == {"brightness": 255, "color_temp": 200, "color_temp_kelvin": 5000} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (27.001, 19.243)} _, data = entity2.last_call("turn_on") @@ -1868,6 +1890,10 @@ async def test_light_service_call_color_temp_conversion( entity1 = platform.ENTITIES[1] entity1.supported_color_modes = {light.ColorMode.RGBWW} + assert entity1.min_mireds == 153 + assert entity1.max_mireds == 500 + assert entity1.min_color_temp_kelvin == 2000 + assert entity1.max_color_temp_kelvin == 6500 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1877,6 +1903,10 @@ async def test_light_service_call_color_temp_conversion( light.ColorMode.COLOR_TEMP, light.ColorMode.RGBWW, ] + assert state.attributes["min_mireds"] == 153 + assert state.attributes["max_mireds"] == 500 + assert state.attributes["min_color_temp_kelvin"] == 2000 + assert state.attributes["max_color_temp_kelvin"] == 6500 state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] @@ -1895,7 +1925,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 153} + assert data == {"brightness": 255, "color_temp": 153, "color_temp_kelvin": 6535} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 153 should be maximum cold at 100% brightness so 255 assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 255, 0)} @@ -1914,7 +1944,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 128, "color_temp": 500} + assert data == {"brightness": 128, "color_temp": 500, "color_temp_kelvin": 2000} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 500 should be maximum warm at 50% brightness so 128 assert data == {"brightness": 128, "rgbww_color": (0, 0, 0, 0, 128)} @@ -1933,7 +1963,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 327} + assert data == {"brightness": 255, "color_temp": 327, "color_temp_kelvin": 3058} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 328 should be the midway point at 100% brightness so 127 (rounding), 128 assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 127, 128)} @@ -1952,7 +1982,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 240} + assert data == {"brightness": 255, "color_temp": 240, "color_temp_kelvin": 4166} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 191, 64)} @@ -1970,11 +2000,57 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 410} + assert data == {"brightness": 255, "color_temp": 410, "color_temp_kelvin": 2439} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 66, 189)} +async def test_light_mired_color_temp_conversion(hass, enable_custom_integrations): + """Test color temp conversion from K to legacy mired.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_rgbww_ct", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = { + light.ColorMode.COLOR_TEMP, + } + entity0._attr_min_color_temp_kelvin = 1800 + entity0._attr_max_color_temp_kelvin = 6700 + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + assert state.attributes["min_mireds"] == 149 + assert state.attributes["max_mireds"] == 555 + assert state.attributes["min_color_temp_kelvin"] == 1800 + assert state.attributes["max_color_temp_kelvin"] == 6700 + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [ + entity0.entity_id, + ], + "brightness_pct": 100, + "color_temp_kelvin": 3500, + }, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"brightness": 255, "color_temp": 285, "color_temp_kelvin": 3500} + + state = hass.states.get(entity0.entity_id) + assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP + assert state.attributes["color_temp"] == 285 + assert state.attributes["color_temp_kelvin"] == 3500 + + async def test_light_service_call_white_mode(hass, enable_custom_integrations): """Test color_mode white in service calls.""" platform = getattr(hass.components, "test.light") diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index b6d26306317209..0ff092545f4b5c 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -21,7 +21,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test light registered attributes to be excluded.""" await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 31ca16102509f9..fb7ac217867683 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -58,7 +58,7 @@ @pytest.fixture -async def hass_(hass, recorder_mock): +async def hass_(recorder_mock, hass): """Set up things to be run when tests are started.""" assert await async_setup_component(hass, logbook.DOMAIN, EMPTY_CONFIG) return hass @@ -123,7 +123,7 @@ async def test_service_call_create_logbook_entry(hass_): assert last_call.data.get(logbook.ATTR_DOMAIN) == "logbook" -async def test_service_call_create_logbook_entry_invalid_entity_id(hass, recorder_mock): +async def test_service_call_create_logbook_entry_invalid_entity_id(recorder_mock, hass): """Test if service call create log book entry with an invalid entity id.""" await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() @@ -352,7 +352,7 @@ def create_state_changed_event_from_old_new( return LazyEventPartialState(row, {}) -async def test_logbook_view(hass, hass_client, recorder_mock): +async def test_logbook_view(recorder_mock, hass, hass_client): """Test the logbook view.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -361,7 +361,7 @@ async def test_logbook_view(hass, hass_client, recorder_mock): assert response.status == HTTPStatus.OK -async def test_logbook_view_invalid_start_date_time(hass, hass_client, recorder_mock): +async def test_logbook_view_invalid_start_date_time(recorder_mock, hass, hass_client): """Test the logbook view with an invalid date time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -370,7 +370,7 @@ async def test_logbook_view_invalid_start_date_time(hass, hass_client, recorder_ assert response.status == HTTPStatus.BAD_REQUEST -async def test_logbook_view_invalid_end_date_time(hass, hass_client, recorder_mock): +async def test_logbook_view_invalid_end_date_time(recorder_mock, hass, hass_client): """Test the logbook view.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -381,7 +381,7 @@ async def test_logbook_view_invalid_end_date_time(hass, hass_client, recorder_mo assert response.status == HTTPStatus.BAD_REQUEST -async def test_logbook_view_period_entity(hass, hass_client, recorder_mock, set_utc): +async def test_logbook_view_period_entity(recorder_mock, hass, hass_client, set_utc): """Test the logbook view with period and entity.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -462,7 +462,7 @@ async def test_logbook_view_period_entity(hass, hass_client, recorder_mock, set_ assert response_json[0]["entity_id"] == entity_id_test -async def test_logbook_describe_event(hass, hass_client, recorder_mock): +async def test_logbook_describe_event(recorder_mock, hass, hass_client): """Test teaching logbook about a new event.""" def _describe(event): @@ -498,7 +498,7 @@ def _describe(event): assert event["domain"] == "test_domain" -async def test_exclude_described_event(hass, hass_client, recorder_mock): +async def test_exclude_described_event(recorder_mock, hass, hass_client): """Test exclusions of events that are described by another integration.""" name = "My Automation Rule" entity_id = "automation.excluded_rule" @@ -561,7 +561,7 @@ def async_describe_events(hass, async_describe_event): assert event["entity_id"] == "automation.included_rule" -async def test_logbook_view_end_time_entity(hass, hass_client, recorder_mock): +async def test_logbook_view_end_time_entity(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -616,7 +616,7 @@ async def test_logbook_view_end_time_entity(hass, hass_client, recorder_mock): assert response_json[0]["entity_id"] == entity_id_test -async def test_logbook_entity_filter_with_automations(hass, hass_client, recorder_mock): +async def test_logbook_entity_filter_with_automations(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -692,7 +692,7 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client, recorde async def test_logbook_entity_no_longer_in_state_machine( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the logbook view with an entity that hass been removed from the state machine.""" await async_setup_component(hass, "logbook", {}) @@ -730,7 +730,7 @@ async def test_logbook_entity_no_longer_in_state_machine( async def test_filter_continuous_sensor_values( - hass, hass_client, recorder_mock, set_utc + recorder_mock, hass, hass_client, set_utc ): """Test remove continuous sensor events from logbook.""" await async_setup_component(hass, "logbook", {}) @@ -770,7 +770,7 @@ async def test_filter_continuous_sensor_values( assert response_json[1]["entity_id"] == entity_id_third -async def test_exclude_new_entities(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_new_entities(recorder_mock, hass, hass_client, set_utc): """Test if events are excluded on first update.""" await asyncio.gather( *[ @@ -807,7 +807,7 @@ async def test_exclude_new_entities(hass, hass_client, recorder_mock, set_utc): assert response_json[1]["message"] == "started" -async def test_exclude_removed_entities(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_removed_entities(recorder_mock, hass, hass_client, set_utc): """Test if events are excluded on last update.""" await asyncio.gather( *[ @@ -851,7 +851,7 @@ async def test_exclude_removed_entities(hass, hass_client, recorder_mock, set_ut assert response_json[2]["entity_id"] == entity_id2 -async def test_exclude_attribute_changes(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_attribute_changes(recorder_mock, hass, hass_client, set_utc): """Test if events of attribute changes are filtered.""" await asyncio.gather( *[ @@ -891,7 +891,7 @@ async def test_exclude_attribute_changes(hass, hass_client, recorder_mock, set_u assert response_json[2]["entity_id"] == "light.kitchen" -async def test_logbook_entity_context_id(hass, recorder_mock, hass_client): +async def test_logbook_entity_context_id(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -1042,7 +1042,7 @@ async def test_logbook_entity_context_id(hass, recorder_mock, hass_client): async def test_logbook_context_id_automation_script_started_manually( - hass, recorder_mock, hass_client + recorder_mock, hass, hass_client ): """Test the logbook populates context_ids for scripts and automations started manually.""" await asyncio.gather( @@ -1132,7 +1132,7 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[4]["context_domain"] == "script" -async def test_logbook_entity_context_parent_id(hass, hass_client, recorder_mock): +async def test_logbook_entity_context_parent_id(recorder_mock, hass, hass_client): """Test the logbook view links events via context parent_id.""" await asyncio.gather( *[ @@ -1311,7 +1311,7 @@ async def test_logbook_entity_context_parent_id(hass, hass_client, recorder_mock assert json_dict[8]["context_user_id"] == "485cacf93ef84d25a99ced3126b921d2" -async def test_logbook_context_from_template(hass, hass_client, recorder_mock): +async def test_logbook_context_from_template(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -1398,7 +1398,7 @@ async def test_logbook_context_from_template(hass, hass_client, recorder_mock): assert json_dict[5]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_(hass, hass_client, recorder_mock): +async def test_logbook_(recorder_mock, hass, hass_client): """Test the logbook view with a single entity and .""" await async_setup_component(hass, "logbook", {}) assert await async_setup_component( @@ -1467,7 +1467,7 @@ async def test_logbook_(hass, hass_client, recorder_mock): assert json_dict[1]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_many_entities_multiple_calls(hass, hass_client, recorder_mock): +async def test_logbook_many_entities_multiple_calls(recorder_mock, hass, hass_client): """Test the logbook view with a many entities called multiple times.""" await async_setup_component(hass, "logbook", {}) await async_setup_component(hass, "automation", {}) @@ -1537,7 +1537,7 @@ async def test_logbook_many_entities_multiple_calls(hass, hass_client, recorder_ assert len(json_dict) == 0 -async def test_custom_log_entry_discoverable_via_(hass, hass_client, recorder_mock): +async def test_custom_log_entry_discoverable_via_(recorder_mock, hass, hass_client): """Test if a custom log entry is later discoverable via .""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1572,7 +1572,7 @@ async def test_custom_log_entry_discoverable_via_(hass, hass_client, recorder_mo assert json_dict[0]["entity_id"] == "switch.test_switch" -async def test_logbook_multiple_entities(hass, hass_client, recorder_mock): +async def test_logbook_multiple_entities(recorder_mock, hass, hass_client): """Test the logbook view with a multiple entities.""" await async_setup_component(hass, "logbook", {}) assert await async_setup_component( @@ -1696,7 +1696,7 @@ async def test_logbook_multiple_entities(hass, hass_client, recorder_mock): assert json_dict[3]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_invalid_entity(hass, hass_client, recorder_mock): +async def test_logbook_invalid_entity(recorder_mock, hass, hass_client): """Test the logbook view with requesting an invalid entity.""" await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() @@ -1714,7 +1714,7 @@ async def test_logbook_invalid_entity(hass, hass_client, recorder_mock): assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_icon_and_state(hass, hass_client, recorder_mock): +async def test_icon_and_state(recorder_mock, hass, hass_client): """Test to ensure state and custom icons are returned.""" await asyncio.gather( *[ @@ -1757,7 +1757,7 @@ async def test_icon_and_state(hass, hass_client, recorder_mock): assert response_json[2]["state"] == STATE_OFF -async def test_fire_logbook_entries(hass, hass_client, recorder_mock): +async def test_fire_logbook_entries(recorder_mock, hass, hass_client): """Test many logbook entry calls.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1793,7 +1793,7 @@ async def test_fire_logbook_entries(hass, hass_client, recorder_mock): assert len(response_json) == 11 -async def test_exclude_events_domain(hass, hass_client, recorder_mock): +async def test_exclude_events_domain(recorder_mock, hass, hass_client): """Test if events are filtered if domain is excluded in config.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -1827,7 +1827,7 @@ async def test_exclude_events_domain(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_exclude_events_domain_glob(hass, hass_client, recorder_mock): +async def test_exclude_events_domain_glob(recorder_mock, hass, hass_client): """Test if events are filtered if domain or glob is excluded in config.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -1870,7 +1870,7 @@ async def test_exclude_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_include_events_entity(hass, hass_client, recorder_mock): +async def test_include_events_entity(recorder_mock, hass, hass_client): """Test if events are filtered if entity is included in config.""" entity_id = "sensor.bla" entity_id2 = "sensor.blu" @@ -1910,7 +1910,7 @@ async def test_include_events_entity(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_exclude_events_entity(hass, hass_client, recorder_mock): +async def test_exclude_events_entity(recorder_mock, hass, hass_client): """Test if events are filtered if entity is excluded in config.""" entity_id = "sensor.bla" entity_id2 = "sensor.blu" @@ -1944,7 +1944,7 @@ async def test_exclude_events_entity(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_include_events_domain(hass, hass_client, recorder_mock): +async def test_include_events_domain(recorder_mock, hass, hass_client): """Test if events are filtered if domain is included in config.""" assert await async_setup_component(hass, "alexa", {}) entity_id = "switch.bla" @@ -1986,7 +1986,7 @@ async def test_include_events_domain(hass, hass_client, recorder_mock): _assert_entry(entries[2], name="blu", entity_id=entity_id2) -async def test_include_events_domain_glob(hass, hass_client, recorder_mock): +async def test_include_events_domain_glob(recorder_mock, hass, hass_client): """Test if events are filtered if domain or glob is included in config.""" assert await async_setup_component(hass, "alexa", {}) entity_id = "switch.bla" @@ -2043,7 +2043,7 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[3], name="included", entity_id=entity_id3) -async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock): +async def test_include_exclude_events_no_globs(recorder_mock, hass, hass_client): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -2100,7 +2100,7 @@ async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock) async def test_include_exclude_events_with_glob_filters( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" @@ -2165,7 +2165,7 @@ async def test_include_exclude_events_with_glob_filters( _assert_entry(entries[6], name="included", entity_id=entity_id5, state="30") -async def test_empty_config(hass, hass_client, recorder_mock): +async def test_empty_config(recorder_mock, hass, hass_client): """Test we can handle an empty entity filter.""" entity_id = "sensor.blu" @@ -2197,7 +2197,7 @@ async def test_empty_config(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id) -async def test_context_filter(hass, hass_client, recorder_mock): +async def test_context_filter(recorder_mock, hass, hass_client): """Test we can filter by context.""" assert await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2269,7 +2269,7 @@ def _assert_entry( assert state == entry["state"] -async def test_get_events(hass, hass_ws_client, recorder_mock): +async def test_get_events(recorder_mock, hass, hass_ws_client): """Test logbook get_events.""" now = dt_util.utcnow() await asyncio.gather( @@ -2387,7 +2387,7 @@ async def test_get_events(hass, hass_ws_client, recorder_mock): assert isinstance(results[0]["when"], float) -async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_future_start_time(recorder_mock, hass, hass_ws_client): """Test get_events with a future start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2410,7 +2410,7 @@ async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock) assert len(results) == 0 -async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_start_time(recorder_mock, hass, hass_ws_client): """Test get_events bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2428,7 +2428,7 @@ async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_start_time" -async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_end_time(recorder_mock, hass, hass_ws_client): """Test get_events bad end time.""" now = dt_util.utcnow() await async_setup_component(hass, "logbook", {}) @@ -2448,7 +2448,7 @@ async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_end_time" -async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): +async def test_get_events_invalid_filters(recorder_mock, hass, hass_ws_client): """Test get_events invalid filters.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2476,7 +2476,7 @@ async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_format" -async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_device_ids(recorder_mock, hass, hass_ws_client): """Test logbook get_events for device ids.""" now = dt_util.utcnow() await asyncio.gather( @@ -2613,7 +2613,7 @@ def async_describe_test_event(event: Event) -> dict[str, str]: assert isinstance(results[3]["when"], float) -async def test_logbook_select_entities_context_id(hass, recorder_mock, hass_client): +async def test_logbook_select_entities_context_id(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -2746,7 +2746,7 @@ async def test_logbook_select_entities_context_id(hass, recorder_mock, hass_clie assert json_dict[3]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_get_events_with_context_state(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_context_state(recorder_mock, hass, hass_ws_client): """Test logbook get_events with a context state.""" now = dt_util.utcnow() await asyncio.gather( @@ -2809,7 +2809,7 @@ async def test_get_events_with_context_state(hass, hass_ws_client, recorder_mock assert "context_event_type" not in results[3] -async def test_logbook_with_empty_config(hass, recorder_mock): +async def test_logbook_with_empty_config(recorder_mock, hass): """Test we handle a empty configuration.""" assert await async_setup_component( hass, @@ -2822,7 +2822,7 @@ async def test_logbook_with_empty_config(hass, recorder_mock): await hass.async_block_till_done() -async def test_logbook_with_non_iterable_entity_filter(hass, recorder_mock): +async def test_logbook_with_non_iterable_entity_filter(recorder_mock, hass): """Test we handle a non-iterable entity filter.""" assert await async_setup_component( hass, diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index a7bd28f0e4d986..fb0defca93c39e 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -123,7 +123,7 @@ async def _async_mock_devices_with_logbook_platform(hass): return [device, device2] -async def test_get_events(hass, hass_ws_client, recorder_mock): +async def test_get_events(recorder_mock, hass, hass_ws_client): """Test logbook get_events.""" now = dt_util.utcnow() await asyncio.gather( @@ -241,7 +241,7 @@ async def test_get_events(hass, hass_ws_client, recorder_mock): assert isinstance(results[0]["when"], float) -async def test_get_events_entities_filtered_away(hass, hass_ws_client, recorder_mock): +async def test_get_events_entities_filtered_away(recorder_mock, hass, hass_ws_client): """Test logbook get_events all entities filtered away.""" now = dt_util.utcnow() await asyncio.gather( @@ -303,7 +303,7 @@ async def test_get_events_entities_filtered_away(hass, hass_ws_client, recorder_ assert len(results) == 0 -async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_future_start_time(recorder_mock, hass, hass_ws_client): """Test get_events with a future start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -326,7 +326,7 @@ async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock) assert len(results) == 0 -async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_start_time(recorder_mock, hass, hass_ws_client): """Test get_events bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -344,7 +344,7 @@ async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_start_time" -async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_end_time(recorder_mock, hass, hass_ws_client): """Test get_events bad end time.""" now = dt_util.utcnow() await async_setup_component(hass, "logbook", {}) @@ -364,7 +364,7 @@ async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_end_time" -async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): +async def test_get_events_invalid_filters(recorder_mock, hass, hass_ws_client): """Test get_events invalid filters.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -392,7 +392,7 @@ async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_format" -async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_device_ids(recorder_mock, hass, hass_ws_client): """Test logbook get_events for device ids.""" now = dt_util.utcnow() await asyncio.gather( @@ -504,7 +504,7 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with excluded entities.""" now = dt_util.utcnow() @@ -528,7 +528,6 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.exc", STATE_ON) hass.states.async_set("light.exc", STATE_OFF) @@ -544,6 +543,7 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -684,12 +684,12 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_included_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with included entities.""" test_entities = ( @@ -722,7 +722,6 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) for entity_id in test_entities: hass.states.async_set(entity_id, STATE_ON) @@ -732,6 +731,7 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -892,12 +892,12 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream inherts filters from recorder.""" now = dt_util.utcnow() @@ -926,7 +926,6 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.exc", STATE_ON) hass.states.async_set("light.exc", STATE_OFF) @@ -943,6 +942,7 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -1083,12 +1083,12 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream.""" now = dt_util.utcnow() @@ -1100,7 +1100,6 @@ async def test_subscribe_unsubscribe_logbook_stream( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1109,6 +1108,7 @@ async def test_subscribe_unsubscribe_logbook_stream( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -1386,12 +1386,12 @@ async def test_subscribe_unsubscribe_logbook_stream( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities.""" now = dt_util.utcnow() @@ -1403,7 +1403,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1412,6 +1411,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1484,12 +1484,12 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities and an end_time.""" now = dt_util.utcnow() @@ -1501,7 +1501,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1510,6 +1509,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1586,12 +1586,17 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) <= init_count + listeners = hass.bus.async_listeners() + # The async_fire_time_changed above triggers unsubscribe from + # homeassistant_final_write, don't worry about those + init_listeners.pop("homeassistant_final_write") + listeners.pop("homeassistant_final_write") + assert listeners == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities in the past.""" now = dt_util.utcnow() @@ -1603,7 +1608,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1612,6 +1616,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1654,12 +1659,12 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_big_query( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream and ask for a large time frame. @@ -1675,7 +1680,6 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) four_days_ago = now - timedelta(days=4) five_days_ago = now - timedelta(days=5) @@ -1699,6 +1703,7 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1754,12 +1759,12 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_device( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with a device.""" now = dt_util.utcnow() @@ -1774,10 +1779,10 @@ async def test_subscribe_unsubscribe_logbook_stream_device( device2 = devices[1] await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1848,10 +1853,10 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners -async def test_event_stream_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_event_stream_bad_start_time(recorder_mock, hass, hass_ws_client): """Test event_stream bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1871,7 +1876,7 @@ async def test_event_stream_bad_start_time(hass, hass_ws_client, recorder_mock): @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_match_multiple_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook stream with a described integration that uses multiple entities.""" now = dt_util.utcnow() @@ -1886,10 +1891,10 @@ async def test_logbook_stream_match_multiple_entities( hass.states.async_set(entity_id, STATE_ON) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1963,10 +1968,10 @@ async def test_logbook_stream_match_multiple_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners -async def test_event_stream_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_event_stream_bad_end_time(recorder_mock, hass, hass_ws_client): """Test event_stream bad end time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1999,8 +2004,8 @@ async def test_event_stream_bad_end_time(hass, hass_ws_client, recorder_mock): async def test_live_stream_with_one_second_commit_interval( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, hass_ws_client, ): """Test the recorder with a 1s commit interval.""" @@ -2017,7 +2022,6 @@ async def test_live_stream_with_one_second_commit_interval( device = devices[0] await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "1"}) @@ -2030,6 +2034,7 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "3"}) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -2086,11 +2091,11 @@ async def test_live_stream_with_one_second_commit_interval( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): +async def test_subscribe_disconnected(recorder_mock, hass, hass_ws_client): """Test subscribe/unsubscribe logbook stream gets disconnected.""" now = dt_util.utcnow() await asyncio.gather( @@ -2101,7 +2106,6 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): ) await async_wait_recording_done(hass) - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2109,6 +2113,9 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): await hass.async_block_till_done() await async_wait_recording_done(hass) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() websocket_client = await hass_ws_client() await websocket_client.send_json( { @@ -2139,11 +2146,11 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_client): +async def test_stream_consumer_stop_processing(recorder_mock, hass, hass_ws_client): """Test we unsubscribe if the stream consumer fails or is canceled.""" now = dt_util.utcnow() await asyncio.gather( @@ -2153,7 +2160,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie ] ) await async_wait_recording_done(hass) - init_count = sum(hass.bus.async_listeners().values()) + init_listeners = hass.bus.async_listeners() hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2162,7 +2169,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie await async_wait_recording_done(hass) websocket_client = await hass_ws_client() - after_ws_created_count = sum(hass.bus.async_listeners().values()) + after_ws_created_listeners = hass.bus.async_listeners() with patch.object(websocket_api, "MAX_PENDING_LOGBOOK_EVENTS", 5), patch.object( websocket_api, "_async_events_consumer" @@ -2182,7 +2189,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie assert msg["type"] == TYPE_RESULT assert msg["success"] - assert sum(hass.bus.async_listeners().values()) != init_count + assert hass.bus.async_listeners() != init_listeners for _ in range(5): hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2190,13 +2197,13 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie # Check our listener got unsubscribed because # the queue got full and the overload safety tripped - assert sum(hass.bus.async_listeners().values()) == after_ws_created_count + assert hass.bus.async_listeners() == after_ws_created_listeners await websocket_client.close() - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplog): +async def test_recorder_is_far_behind(recorder_mock, hass, hass_ws_client, caplog): """Test we still start live streaming if the recorder is far behind.""" now = dt_util.utcnow() await asyncio.gather( @@ -2272,7 +2279,7 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_are_continuous( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" now = dt_util.utcnow() @@ -2294,7 +2301,9 @@ def _cycle_entities(): hass.states.async_set("counter.any", state) hass.states.async_set("proximity.any", state) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2323,12 +2332,12 @@ def _cycle_entities(): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_have_uom_multiple( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook stream with specific request for multiple entities that are always filtered.""" now = dt_util.utcnow() @@ -2348,7 +2357,9 @@ def _cycle_entities(): entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} ) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2378,14 +2389,14 @@ def _cycle_entities(): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_entities_some_have_uom_multiple( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): - """Test logbook stream with uom filtered entities and non-fitlered entities.""" + """Test logbook stream with uom filtered entities and non-filtered entities.""" now = dt_util.utcnow() await asyncio.gather( *[ @@ -2407,7 +2418,9 @@ def _cycle_entities(): for state in (STATE_ON, STATE_OFF): hass.states.async_set(entity_id, state) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2481,12 +2494,12 @@ def _cycle_entities(): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_ignores_forced_updates( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook live stream ignores forced updates.""" now = dt_util.utcnow() @@ -2498,7 +2511,6 @@ async def test_logbook_stream_ignores_forced_updates( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2507,6 +2519,7 @@ async def test_logbook_stream_ignores_forced_updates( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -2595,12 +2608,12 @@ async def test_logbook_stream_ignores_forced_updates( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_are_continuous_with_device( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" now = dt_util.utcnow() @@ -2628,7 +2641,9 @@ def _create_events(): hass.bus.async_fire("mock_event", {"device_id": device.id}) hass.bus.async_fire("mock_event", {"device_id": device2.id}) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _create_events() await async_wait_recording_done(hass) @@ -2688,4 +2703,4 @@ def _create_events(): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 91ddfe26fb56da..5e3db30ad5b368 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -105,11 +105,11 @@ def __init__(self, can_connect=True): """Initialize MockBridge instance with configured mock connectivity.""" self.can_connect = can_connect self.is_currently_connected = False - self.buttons = {} - self.areas = {} + self.areas = self.load_areas() self.occupancy_groups = {} self.scenes = self.get_scenes() self.devices = self.load_devices() + self.buttons = self.load_buttons() async def connect(self): """Connect the mock bridge.""" @@ -119,14 +119,36 @@ async def connect(self): def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + def add_button_subscriber(self, button_id: str, callback_): + """Mock a listener for button presses.""" + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected + def load_areas(self): + """Loak mock areas into self.areas.""" + return { + "3": {"id": "3", "name": "House", "parent_id": None}, + "898": {"id": "898", "name": "Basement", "parent_id": "3"}, + "822": {"id": "822", "name": "Bedroom", "parent_id": "898"}, + "910": {"id": "910", "name": "Bathroom", "parent_id": "898"}, + "1024": {"id": "1024", "name": "Master Bedroom", "parent_id": "3"}, + "1025": {"id": "1025", "name": "Kitchen", "parent_id": "3"}, + "1026": {"id": "1026", "name": "Dining Room", "parent_id": "3"}, + "1205": {"id": "1205", "name": "Hallway", "parent_id": "3"}, + } + def load_devices(self): """Load mock devices into self.devices.""" return { - "1": {"serial": 1234, "name": "bridge", "model": "model", "type": "type"}, + "1": { + "serial": 1234, + "name": "bridge", + "model": "model", + "type": "type", + "area": "1205", + }, "801": { "device_id": "801", "current_state": 100, @@ -138,6 +160,7 @@ def load_devices(self): "model": None, "serial": None, "tilt": None, + "area": "822", }, "802": { "device_id": "802", @@ -150,6 +173,7 @@ def load_devices(self): "model": None, "serial": None, "tilt": None, + "area": "822", }, "803": { "device_id": "803", @@ -162,6 +186,7 @@ def load_devices(self): "model": None, "serial": None, "tilt": None, + "area": "910", }, "804": { "device_id": "804", @@ -174,6 +199,7 @@ def load_devices(self): "model": None, "serial": None, "tilt": None, + "area": "1024", }, "901": { "device_id": "901", @@ -186,6 +212,65 @@ def load_devices(self): "model": None, "serial": 5442321, "tilt": None, + "area": "1025", + }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "1026", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", + }, + } + + def load_buttons(self): + """Load mock buttons into self.buttons.""" + return { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 1, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", }, } @@ -228,6 +313,13 @@ def get_scenes(self): """Return scenes on the bridge.""" return {} + def get_buttons(self): + """Will return all known buttons connected to the bridge/processor.""" + return self.buttons + + def tap_button(self, button_id: str): + """Mock a button press and release message for the given button ID.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_button.py b/tests/components/lutron_caseta/test_button.py new file mode 100644 index 00000000000000..68742e5bae3aec --- /dev/null +++ b/tests/components/lutron_caseta/test_button.py @@ -0,0 +1,50 @@ +"""Tests for the Lutron Caseta integration.""" + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_button_unique_id(hass: HomeAssistant) -> None: + """Test a button unique id.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + caseta_button_entity_id = "button.dining_room_pico_stop" + + entity_registry = er.async_get(hass) + + # Assert that Caseta buttons will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(ra3_button_entity_id).unique_id == "000004d2_1372" + assert ( + entity_registry.async_get(caseta_button_entity_id).unique_id == "000004d2_111" + ) + + +async def test_button_press(hass: HomeAssistant) -> None: + """Test a button press.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + + state = hass.states.get(ra3_button_entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ra3_button_entity_id}, + blocking=False, + ) + await hass.async_block_till_done() + + state = hass.states.get(ra3_button_entity_id) + assert state diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 161f5cf357f58f..a1558822619add 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,5 +1,5 @@ """The tests for Lutron Caséta device triggers.""" -from unittest.mock import MagicMock +from unittest.mock import patch import pytest @@ -14,16 +14,26 @@ ) from homeassistant.components.lutron_caseta.const import ( ATTR_LEAP_BUTTON_NUMBER, + CONF_CA_CERTS, + CONF_CERTFILE, + CONF_KEYFILE, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, - MANUFACTURER, ) from homeassistant.components.lutron_caseta.device_trigger import CONF_SUBTYPE from homeassistant.components.lutron_caseta.models import LutronCasetaData -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_HOST, + CONF_PLATFORM, + CONF_TYPE, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component +from . import MockBridge + from tests.common import ( MockConfigEntry, assert_lists_same, @@ -34,7 +44,8 @@ MOCK_BUTTON_DEVICES = [ { - "Name": "Back Hall Pico", + "device_id": "9", + "Name": "Dining Room_Pico", "ID": 2, "Area": {"Name": "Back Hall"}, "Buttons": [ @@ -44,13 +55,14 @@ {"Number": 5}, {"Number": 6}, ], - "leap_name": "Back Hall_Back Hall Pico", + "leap_name": "Dining Room_Pico", "type": "Pico3ButtonRaiseLower", "model": "PJ2-3BRL-GXX-X01", - "serial": 43845548, + "serial": 68551522, }, { - "Name": "Front Steps Sunnata Keypad", + "device_id": "1355", + "Name": "Main Stairs Position 1 Keypad", "ID": 3, "Area": {"Name": "Front Steps"}, "Buttons": [ @@ -63,7 +75,26 @@ "leap_name": "Front Steps_Front Steps Sunnata Keypad", "type": "SunnataKeypad", "model": "RRST-W4B-XX", - "serial": 43845547, + "serial": 66286451, + }, + { + "device_id": "786", + "Name": "Example Homeowner Keypad", + "ID": 4, + "Area": {"Name": "Front Steps"}, + "Buttons": [ + {"Number": 12}, + {"Number": 13}, + {"Number": 14}, + {"Number": 15}, + {"Number": 16}, + {"Number": 17}, + {"Number": 18}, + ], + "leap_name": "Front Steps_Example Homeowner Keypad", + "type": "HomeownerKeypad", + "model": "Homeowner Keypad", + "serial": "1234_786", }, ] @@ -80,36 +111,36 @@ def device_reg(hass): return mock_device_registry(hass) -async def _async_setup_lutron_with_picos(hass, device_reg): +async def _async_setup_lutron_with_picos(hass): """Setups a lutron bridge with picos.""" - await async_setup_component(hass, DOMAIN, {}) - - config_entry = MockConfigEntry(domain=DOMAIN, data={}) - config_entry.add_to_hass(hass) - dr_button_devices = {} - - for device in MOCK_BUTTON_DEVICES: - dr_device = device_reg.async_get_or_create( - name=device["leap_name"], - manufacturer=MANUFACTURER, - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, device["serial"])}, - model=f"{device['model']} ({device[CONF_TYPE]})", - ) - dr_button_devices[dr_device.id] = device - - hass.data[DOMAIN][config_entry.entry_id] = LutronCasetaData( - MagicMock(), MagicMock(), dr_button_devices + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", + }, + unique_id="abc", ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + return_value=MockBridge(can_connect=True), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry.entry_id async def test_get_triggers(hass, device_reg): """Test we get the expected triggers from a lutron pico.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] + keypads = data.keypad_data.keypads + device_id = keypads[list(keypads)[0]]["dr_device_id"] expected_triggers = [ { @@ -143,7 +174,7 @@ async def test_get_triggers(hass, device_reg): async def test_get_triggers_for_invalid_device_id(hass, device_reg): """Test error raised for invalid lutron device_id.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) invalid_device = device_reg.async_get_or_create( config_entry_id=config_entry_id, @@ -159,7 +190,7 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg): async def test_get_triggers_for_non_button_device(hass, device_reg): """Test error raised for invalid lutron device_id.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) invalid_device = device_reg.async_get_or_create( config_entry_id=config_entry_id, @@ -173,13 +204,27 @@ async def test_get_triggers_for_non_button_device(hass, device_reg): assert triggers == [] +async def test_none_serial_keypad(hass, device_reg): + """Test serial assignment for keypads without serials.""" + config_entry_id = await _async_setup_lutron_with_picos(hass) + + keypad_device = device_reg.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, "1234_786")}, + ) + + assert keypad_device is not None + + async def test_if_fires_on_button_event(hass, calls, device_reg): """Test for press trigger firing.""" - await _async_setup_lutron_with_picos(hass, device_reg) + await _async_setup_lutron_with_picos(hass) + device = MOCK_BUTTON_DEVICES[0] dr = device_registry.async_get(hass) dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) device_id = dr_device.id + assert await async_setup_component( hass, automation.DOMAIN, @@ -219,7 +264,7 @@ async def test_if_fires_on_button_event(hass, calls, device_reg): async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): """Test for press trigger firing on a device that does not support lip.""" - await _async_setup_lutron_with_picos(hass, device_reg) + await _async_setup_lutron_with_picos(hass) device = MOCK_BUTTON_DEVICES[1] dr = device_registry.async_get(hass) dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) @@ -235,7 +280,7 @@ async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, CONF_TYPE: "press", - CONF_SUBTYPE: "button_1", + CONF_SUBTYPE: "Kitchen Pendants", }, "action": { "service": "test.automation", @@ -249,7 +294,7 @@ async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): message = { ATTR_SERIAL: device.get("serial"), ATTR_TYPE: device.get("type"), - ATTR_LEAP_BUTTON_NUMBER: 1, + ATTR_LEAP_BUTTON_NUMBER: 3, ATTR_DEVICE_NAME: device["Name"], ATTR_AREA_NAME: device.get("Area", {}).get("Name"), ATTR_ACTION: "press", @@ -302,12 +347,13 @@ async def test_validate_trigger_config_no_device(hass, calls, device_reg): async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): """Test for no press with an unknown device.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] - device = dr_button_devices[device_id] - device["type"] = "unknown" + keypads = data.keypad_data.keypads + lutron_device_id = list(keypads)[0] + keypad = keypads[lutron_device_id] + device_id = keypad["dr_device_id"] + keypad["type"] = "unknown" assert await async_setup_component( hass, @@ -346,10 +392,13 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): async def test_validate_trigger_invalid_triggers(hass, device_reg): """Test for click_event with invalid triggers.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] + keypads = data.keypad_data.keypads + lutron_device_id = list(keypads)[0] + keypad = keypads[lutron_device_id] + device_id = keypad["dr_device_id"] + assert await async_setup_component( hass, automation.DOMAIN, diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 42fc1dac5c19ff..98a5b26e8090c0 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Lutron Caseta diagnostics.""" -from unittest.mock import patch +from unittest.mock import ANY, patch from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( @@ -39,15 +39,50 @@ async def test_diagnostics(hass, hass_client) -> None: diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { - "data": { - "areas": {}, - "buttons": {}, + "bridge_data": { + "areas": { + "3": {"id": "3", "name": "House", "parent_id": None}, + "898": {"id": "898", "name": "Basement", "parent_id": "3"}, + "822": {"id": "822", "name": "Bedroom", "parent_id": "898"}, + "910": {"id": "910", "name": "Bathroom", "parent_id": "898"}, + "1024": {"id": "1024", "name": "Master Bedroom", "parent_id": "3"}, + "1025": {"id": "1025", "name": "Kitchen", "parent_id": "3"}, + "1026": {"id": "1026", "name": "Dining Room", "parent_id": "3"}, + "1205": {"id": "1205", "name": "Hallway", "parent_id": "3"}, + }, + "buttons": { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 1, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", + }, + }, "devices": { "1": { "model": "model", "name": "bridge", "serial": 1234, "type": "type", + "area": "1205", }, "801": { "device_id": "801", @@ -60,6 +95,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "822", }, "802": { "device_id": "802", @@ -72,6 +108,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "822", }, "803": { "device_id": "803", @@ -84,6 +121,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "910", }, "804": { "device_id": "804", @@ -96,6 +134,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "1024", }, "901": { "device_id": "901", @@ -108,6 +147,36 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": 5442321, "tilt": None, + "area": "1025", + }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "1026", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", }, }, "occupancy_groups": {}, @@ -117,4 +186,66 @@ async def test_diagnostics(hass, hass_client) -> None: "data": {"ca_certs": "", "certfile": "", "host": "1.1.1.1", "keyfile": ""}, "title": "Mock Title", }, + "integration_data": { + "keypad_button_names_to_leap": { + "1355": {"Kitchen Pendants": 3}, + "9": {"Stop": 1}, + }, + "keypad_buttons": { + "111": { + "button_name": "Stop", + "leap_button_number": 1, + "led_device_id": None, + "lutron_device_id": "111", + "parent_keypad": "9", + }, + "1372": { + "button_name": "Kitchen " "Pendants", + "leap_button_number": 3, + "led_device_id": "1362", + "lutron_device_id": "1372", + "parent_keypad": "1355", + }, + }, + "keypads": { + "1355": { + "area_id": "1205", + "area_name": "Hallway", + "buttons": ["1372"], + "device_info": { + "identifiers": [["lutron_caseta", 66286451]], + "manufacturer": "Lutron " "Electronics " "Co., " "Inc", + "model": "RRST-W3RL-XX " "(SunnataKeypad)", + "name": "Hallway " "Main " "Stairs " "Position 1 " "Keypad", + "suggested_area": "Hallway", + "via_device": ["lutron_caseta", 1234], + }, + "dr_device_id": ANY, + "lutron_device_id": "1355", + "model": "RRST-W3RL-XX", + "name": "Main Stairs Position 1 " "Keypad", + "serial": 66286451, + "type": "SunnataKeypad", + }, + "9": { + "area_id": "1026", + "area_name": "Dining Room", + "buttons": ["111"], + "device_info": { + "identifiers": [["lutron_caseta", 68551522]], + "manufacturer": "Lutron " "Electronics " "Co., " "Inc", + "model": "PJ2-3BRL-GXX-X01 " "(Pico3ButtonRaiseLower)", + "name": "Dining Room " "Pico", + "suggested_area": "Dining " "Room", + "via_device": ["lutron_caseta", 1234], + }, + "dr_device_id": ANY, + "lutron_device_id": "9", + "model": "PJ2-3BRL-GXX-X01", + "name": "Pico", + "serial": 68551522, + "type": "Pico3ButtonRaiseLower", + }, + }, + }, } diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 3a202eadf58cce..c189238d9df3c6 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -15,6 +15,7 @@ DOMAIN, LUTRON_CASETA_BUTTON_EVENT, ) +from homeassistant.components.lutron_caseta.models import LutronCasetaData from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST from homeassistant.setup import async_setup_component @@ -49,25 +50,30 @@ async def test_humanify_lutron_caseta_button_event(hass): await hass.async_block_till_done() + data: LutronCasetaData = hass.data[DOMAIN][config_entry.entry_id] + keypads = data.keypad_data.keypads + keypad = keypads["9"] + dr_device_id = keypad["dr_device_id"] + (event1,) = mock_humanify( hass, [ MockRow( LUTRON_CASETA_BUTTON_EVENT, { - ATTR_SERIAL: "123", - ATTR_DEVICE_ID: "1234", + ATTR_SERIAL: "68551522", + ATTR_DEVICE_ID: dr_device_id, ATTR_TYPE: "Pico3ButtonRaiseLower", - ATTR_LEAP_BUTTON_NUMBER: 3, - ATTR_BUTTON_NUMBER: 3, + ATTR_LEAP_BUTTON_NUMBER: 1, + ATTR_BUTTON_NUMBER: 1, ATTR_DEVICE_NAME: "Pico", - ATTR_AREA_NAME: "Living Room", + ATTR_AREA_NAME: "Dining Room", ATTR_ACTION: "press", }, ), ], ) - assert event1["name"] == "Living Room Pico" + assert event1["name"] == "Dining Room Pico" assert event1["domain"] == DOMAIN - assert event1["message"] == "press raise" + assert event1["message"] == "press stop" diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py index 2284101fa84ff8..4484e6b7783d07 100644 --- a/tests/components/mazda/test_sensor.py +++ b/tests/components/mazda/test_sensor.py @@ -16,7 +16,7 @@ PRESSURE_PSI, ) from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration @@ -132,7 +132,7 @@ async def test_sensors(hass): async def test_sensors_imperial_units(hass): """Test that the sensors work properly with imperial units.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await init_integration(hass) diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index dd1329be81e85c..34397a58c0cad5 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -22,7 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test media_player registered attributes to be excluded.""" await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 1b5af1f8abf8ec..c06933b7404531 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from melnor_bluetooth.device import Device from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak @@ -14,6 +13,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" @@ -30,7 +30,7 @@ service_data={}, source="local", device=BLEDevice(FAKE_ADDRESS_1, None), - advertisement=AdvertisementData(local_name=""), + advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, ) @@ -46,7 +46,7 @@ service_data={}, source="local", device=BLEDevice(FAKE_ADDRESS_2, None), - advertisement=AdvertisementData(local_name=""), + advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, ) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 6a71806cea9a4f..6a2945c406bdf2 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -162,3 +162,99 @@ async def test_wrong_credentials(hass, auth_error): CONF_USERNAME: "invalid_auth", CONF_PASSWORD: "invalid_auth", } + + +async def test_reauth_success(hass, api): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {CONF_USERNAME: "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_failed(hass, auth_error): + """Test reauth fails due to wrong password.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == { + CONF_PASSWORD: "invalid_auth", + } + + +async def test_reauth_failed_conn_error(hass, conn_error): + """Test reauth failed due to connection error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index 47435dfbaf3daf..4819cc31a9b730 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -14,6 +14,7 @@ TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -63,6 +64,7 @@ async def test_min_sensor(hass): "name": "test_min", "type": "min", "entity_ids": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", } } @@ -81,6 +83,10 @@ async def test_min_sensor(hass): assert entity_ids[2] == state.attributes.get("min_entity_id") assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.test_min") + assert entity.unique_id == "very_unique_id" + async def test_max_sensor(hass): """Test the max sensor.""" diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 930fb522c4c5cd..13e11db3effbcc 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -13,14 +13,14 @@ TEMP_FAHRENHEIT, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @pytest.mark.parametrize( "unit_system, state_unit, state1, state2", ( (METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), - (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), ), ) async def test_sensor( @@ -124,9 +124,9 @@ async def test_sensor( "unique_id, unit_system, state_unit, state1, state2", ( ("battery_temperature", METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), - ("battery_temperature", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + ("battery_temperature", US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), # The unique_id doesn't match that of the mobile app's battery temperature sensor - ("battery_temp", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), + ("battery_temp", US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), ), ) async def test_sensor_migration( diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index bca633215978e0..777d284e20f3c4 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, + CALL_TYPE_REGISTER_HOLDING, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, @@ -81,6 +82,15 @@ async def test_config_binary_sensor(hass, mock_modbus): }, ], }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, ], ) @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 942f6997c214da..e554160d5bb5da 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,10 +1,18 @@ """The tests for the Modbus climate component.""" import pytest -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + HVACMode, +) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_LAZY_ERROR, CONF_TARGET_TEMP, MODBUS_DOMAIN, @@ -52,6 +60,40 @@ } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + } + ], + }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + HVACMode.DRY.value: 4, + HVACMode.FAN_ONLY.value: 5, + HVACMode.AUTO.value: 6, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass, mock_modbus): @@ -59,6 +101,62 @@ async def test_config_climate(hass, mock_modbus): assert CLIMATE_DOMAIN in hass.config.components +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + }, + }, + } + ], + }, + ], +) +async def test_config_hvac_mode_register(hass, mock_modbus): + """Run configuration test for 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] + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.HEAT_COOL 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_HVAC_ONOFF_REGISTER: 11, + } + ], + }, + ], +) +async def test_config_hvac_onoff_register(hass, mock_modbus): + """Run configuration test for On/Off register.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -90,28 +188,93 @@ async def test_temperature_climate(hass, expected, mock_do_cycle): @pytest.mark.parametrize( - "do_config", + "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_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_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.COOL, + [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_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.HEAT, + [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_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 2, + HVACMode.DRY.value: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + HVACMode.OFF, + [0x00], + ), ], ) -async def test_service_climate_update(hass, mock_modbus, mock_ha): +async def test_service_climate_update( + hass, mock_modbus, mock_ha, result, register_words +): """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 ) - assert hass.states.get(ENTITY_ID).state == "auto" + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == result @pytest.mark.parametrize( @@ -195,6 +358,68 @@ async def test_service_climate_set_temperature( ) +@pytest.mark.parametrize( + "hvac_mode, result, do_config", + [ + ( + HVACMode.COOL, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + } + ] + }, + ), + ( + HVACMode.HEAT, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + } + ] + }, + ), + ], +) +async def test_service_set_mode(hass, hvac_mode, result, mock_modbus, mock_ha): + """Test set mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_hvac_mode", + { + "entity_id": ENTITY_ID, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 6fe38ccf7a120e..edb987e5664257 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -75,7 +75,11 @@ async def test_hassio_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -351,7 +355,11 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.ABORT @@ -366,7 +374,11 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.ABORT @@ -382,7 +394,11 @@ async def test_hassio_abort_if_already_in_progress(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result2.get("type") == data_entry_flow.FlowResultType.ABORT @@ -394,7 +410,11 @@ async def test_hassio_clean_up_on_user_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index 541db872b51b15..b4803c52a6d5ba 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -251,13 +251,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "thumbnail": None, "children": [ { - "title": "00-26-22.mp4", + "title": "00-02-27.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-26-22.mp4" + "/2021-04-25/00-02-27.mp4" ), "can_play": True, "can_expand": False, @@ -265,13 +265,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "children_media_class": None, }, { - "title": "00-36-49.mp4", + "title": "00-26-22.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-36-49.mp4" + "/2021-04-25/00-26-22.mp4" ), "can_play": True, "can_expand": False, @@ -279,13 +279,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "children_media_class": None, }, { - "title": "00-02-27.mp4", + "title": "00-36-49.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-02-27.mp4" + "/2021-04-25/00-36-49.mp4" ), "can_play": True, "can_expand": False, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5d67b34db5d15e..eef728664aab7e 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -1,5 +1,8 @@ """Test config flow.""" +from random import getrandbits +from ssl import SSLError from unittest.mock import AsyncMock, patch +from uuid import uuid4 import pytest import voluptuous as vol @@ -13,6 +16,9 @@ from tests.common import MockConfigEntry +MOCK_CLIENT_CERT = b"## mock client certificate file ##" +MOCK_CLIENT_KEY = b"## mock key file ##" + @pytest.fixture(autouse=True) def mock_finish_setup(): @@ -23,6 +29,43 @@ def mock_finish_setup(): yield mock_finish +@pytest.fixture +def mock_client_cert_check_fail(): + """Mock the client certificate check.""" + with patch( + "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate", + side_effect=ValueError, + ) as mock_cert_check: + yield mock_cert_check + + +@pytest.fixture +def mock_client_key_check_fail(): + """Mock the client key file check.""" + with patch( + "homeassistant.components.mqtt.config_flow.load_pem_private_key", + side_effect=ValueError, + ) as mock_key_check: + yield mock_key_check + + +@pytest.fixture +def mock_ssl_context(): + """Mock the SSL context used to load the cert chain and to load verify locations.""" + with patch( + "homeassistant.components.mqtt.config_flow.SSLContext" + ) as mock_context, patch( + "homeassistant.components.mqtt.config_flow.load_pem_private_key" + ) as mock_key_check, patch( + "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" + ) as mock_cert_check: + yield { + "context": mock_context, + "load_pem_x509_certificate": mock_cert_check, + "load_pem_private_key": mock_key_check, + } + + @pytest.fixture def mock_reload_after_entry_update(): """Mock out the reload after updating the entry.""" @@ -84,6 +127,45 @@ def mock_try_connection_time_out(): yield mock_client() +@pytest.fixture +def mock_process_uploaded_file(tmp_path): + """Mock upload certificate files.""" + file_id_ca = str(uuid4()) + file_id_cert = str(uuid4()) + file_id_key = str(uuid4()) + + def _mock_process_uploaded_file(hass, file_id): + if file_id == file_id_ca: + with open(tmp_path / "ca.crt", "wb") as cafile: + cafile.write(b"## mock CA certificate file ##") + return tmp_path / "ca.crt" + elif file_id == file_id_cert: + with open(tmp_path / "client.crt", "wb") as certfile: + certfile.write(b"## mock client certificate file ##") + return tmp_path / "client.crt" + elif file_id == file_id_key: + with open(tmp_path / "client.key", "wb") as keyfile: + keyfile.write(b"## mock key file ##") + return tmp_path / "client.key" + else: + assert False + + with patch( + "homeassistant.components.mqtt.config_flow.process_uploaded_file", + side_effect=_mock_process_uploaded_file, + ) as mock_upload, patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + "home-assistant-mqtt" + f"-{getrandbits(10):03x}", + ): + mock_upload.file_id = { + mqtt.CONF_CERTIFICATE: file_id_ca, + mqtt.CONF_CLIENT_CERT: file_id_cert, + mqtt.CONF_CLIENT_KEY: file_id_key, + } + yield mock_upload + + async def test_user_connection_works( hass, mock_try_connection, mock_finish_setup, mqtt_client_mock ): @@ -96,7 +178,7 @@ async def test_user_connection_works( assert result["type"] == "form" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"broker": "127.0.0.1"} + result["flow_id"], {"broker": "127.0.0.1", "advanced_options": False} ) assert result["type"] == "create_entry" @@ -104,6 +186,7 @@ async def test_user_connection_works( "broker": "127.0.0.1", "port": 1883, "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -184,19 +267,15 @@ async def test_manual_config_set( "broker": "127.0.0.1", "port": 1883, "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { - "broker": "bla", - "keepalive": 60, - "discovery_prefix": "homeassistant", - "protocol": "3.1.1", + "broker": "127.0.0.1", + "port": 1883, + "discovery": True, }, - "127.0.0.1", - 1883, - None, - None, ) # Check config entry got setup assert len(mock_finish_setup.mock_calls) == 1 @@ -240,7 +319,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "host": "mock-mosquitto", "port": "1883", "protocol": "3.1.1", - } + }, + name="Mosquitto", + slug="mosquitto", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -264,7 +345,9 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "password": "mock-pass", "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA "ssl": False, # Set by the addon's discovery, ignored by HA - } + }, + name="Mock Addon", + slug="mosquitto", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -284,6 +367,7 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "username": "mock-user", "password": "mock-pass", "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection_success.mock_calls) @@ -291,6 +375,46 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set assert len(mock_finish_setup.mock_calls) == 1 +async def test_hassio_cannot_connect( + hass, mock_try_connection_time_out, mock_finish_setup +): + """Test a config flow is aborted when a connection was not successful.""" + mock_try_connection.return_value = True + + result = await hass.config_entries.flow.async_init( + "mqtt", + data=HassioServiceInfo( + config={ + "addon": "Mock Addon", + "host": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA + "ssl": False, # Set by the addon's discovery, ignored by HA + }, + name="Mock Addon", + slug="mosquitto", + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result["type"] == "form" + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Mock Addon"} + + mock_try_connection_time_out.reset_mock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"discovery": True} + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "cannot_connect" + # Check we tried the connection + assert len(mock_try_connection_time_out.mock_calls) + # Check config entry got setup + assert len(mock_finish_setup.mock_calls) == 0 + + @patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}), @@ -299,7 +423,7 @@ async def test_option_flow( hass, mqtt_mock_entry_no_yaml_config, mock_try_connection, - mock_reload_after_entry_update, + caplog, ): """Test config flow options.""" mqtt_mock = await mqtt_mock_entry_no_yaml_config() @@ -335,6 +459,7 @@ async def test_option_flow( result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + "discovery_prefix": "homeassistant", "birth_enable": True, "birth_topic": "ha_state/online", "birth_payload": "online", @@ -355,6 +480,7 @@ async def test_option_flow( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", mqtt.ATTR_PAYLOAD: "online", @@ -372,7 +498,164 @@ async def test_option_flow( await hass.async_block_till_done() assert config_entry.title == "another-broker" # assert that the entry was reloaded with the new config - assert mock_reload_after_entry_update.call_count == 1 + assert ( + "" + in caplog.text + ) + + +@pytest.mark.parametrize( + "test_error", + [ + "bad_certificate", + "bad_client_cert", + "bad_client_key", + "bad_client_cert_key", + "invalid_inclusion", + None, + ], +) +async def test_bad_certificate( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection_success, + tmp_path, + mock_ssl_context, + test_error, + mock_process_uploaded_file, +): + """Test bad certificate tests.""" + # Mock certificate files + file_id = mock_process_uploaded_file.file_id + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + "set_ca_cert": True, + "set_client_cert": True, + } + set_client_cert = True + set_ca_cert = "custom" + tls_insecure = False + if test_error == "bad_certificate": + # CA chain is not loading + mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + elif test_error == "bad_client_cert": + # Client certificate is invalid + mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError + elif test_error == "bad_client_key": + # Client key file is invalid + mock_ssl_context["load_pem_private_key"].side_effect = ValueError + elif test_error == "bad_client_cert_key": + # Client key file file and certificate do not pair + mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError + elif test_error == "invalid_inclusion": + # Client key file without client cert, client cert without key file + test_input.pop(mqtt.CONF_CLIENT_KEY) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Add at least one advanced option to get the full form + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_CLIENT_ID: "custom1234", + mqtt.CONF_KEEPALIVE: 60, + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_PROTOCOL: "3.1.1", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_KEEPALIVE: 60, + "set_client_cert": set_client_cert, + "set_ca_cert": set_ca_cert, + mqtt.CONF_TLS_INSECURE: tls_insecure, + mqtt.CONF_PROTOCOL: "3.1.1", + mqtt.CONF_CLIENT_ID: "custom1234", + }, + ) + test_input["set_client_cert"] = set_client_cert + test_input["set_ca_cert"] = set_ca_cert + test_input["tls_insecure"] = tls_insecure + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + if test_error is not None: + assert result["errors"]["base"] == test_error + return + assert result["errors"] == {} + + +@pytest.mark.parametrize( + "input_value, error", + [ + ("", True), + ("-10", True), + ("10", True), + ("15", False), + ("26", False), + ("100", False), + ], +) +async def test_keepalive_validation( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, + input_value, + error, +): + """Test validation of the keep alive option.""" + + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_KEEPALIVE: input_value, + } + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Add at least one advanced option to get the full form + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_CLIENT_ID: "custom1234", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + if error: + with pytest.raises(vol.MultipleInvalid): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + return + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + assert not result["errors"] async def test_disable_birth_will( @@ -415,6 +698,7 @@ async def test_disable_birth_will( result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", "birth_enable": False, "birth_topic": "ha_state/online", "birth_payload": "online", @@ -435,6 +719,7 @@ async def test_disable_birth_will( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, mqtt.CONF_WILL_MESSAGE: {}, } @@ -444,6 +729,64 @@ async def test_disable_birth_will( assert mock_reload_after_entry_update.call_count == 1 +async def test_invalid_discovery_prefix( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, +): + """Test setting an invalid discovery prefix.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "options" + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 0 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant#invalid", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "options" + assert result["errors"]["base"] == "bad_discovery_prefix" + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", + } + + await hass.async_block_till_done() + # assert that the entry was not reloaded with the new config + assert mock_reload_after_entry_update.call_count == 0 + + def get_default(schema, key): """Get default value for key in voluptuous schema.""" for k in schema.keys(): @@ -614,6 +957,47 @@ async def test_option_flow_default_suggested_values( await hass.async_block_till_done() +@pytest.mark.parametrize( + "advanced_options, step_id", [(False, "options"), (True, "broker")] +) +async def test_skipping_advanced_options( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, + advanced_options, + step_id, +): + """Test advanced options option.""" + + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + "advanced_options": advanced_options, + } + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Initiate with a basic setup + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + assert result["step_id"] == step_id + + async def test_options_user_connection_fails(hass, mock_try_connection_time_out): """Test if connection cannot be made.""" config_entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -716,50 +1100,57 @@ async def test_options_bad_will_message_fails(hass, mock_try_connection): async def test_try_connection_with_advanced_parameters( - hass, mock_try_connection_success, tmp_path + hass, + mqtt_mock_entry_with_yaml_config, + mock_try_connection_success, + tmp_path, + mock_ssl_context, + mock_process_uploaded_file, ): """Test config flow with advanced parameters from config.""" - # Mock certificate files - certfile = tmp_path / "cert.pem" - certfile.write_text("## mock certificate file ##") - keyfile = tmp_path / "key.pem" - keyfile.write_text("## mock key file ##") + + with open(tmp_path / "client.crt", "wb") as certfile: + certfile.write(MOCK_CLIENT_CERT) + with open(tmp_path / "client.key", "wb") as keyfile: + keyfile.write(MOCK_CLIENT_KEY) + config = { "certificate": "auto", "tls_insecure": True, - "client_cert": certfile, - "client_key": keyfile, + "client_cert": str(tmp_path / "client.crt"), + "client_key": str(tmp_path / "client.key"), } new_yaml_config_file = tmp_path / "configuration.yaml" new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, + } + with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) - config_entry.add_to_hass(hass) - config_entry.data = { - mqtt.CONF_BROKER: "test-broker", - mqtt.CONF_PORT: 1234, - mqtt.CONF_USERNAME: "user", - mqtt.CONF_PASSWORD: "pass", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "ha_state/online", - mqtt.ATTR_PAYLOAD: "online", - mqtt.ATTR_QOS: 1, - mqtt.ATTR_RETAIN: True, - }, - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "ha_state/offline", - mqtt.ATTR_PAYLOAD: "offline", - mqtt.ATTR_QOS: 2, - mqtt.ATTR_RETAIN: False, - }, - } - # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -767,16 +1158,32 @@ async def test_try_connection_with_advanced_parameters( defaults = { mqtt.CONF_BROKER: "test-broker", mqtt.CONF_PORT: 1234, + "set_client_cert": True, + "set_ca_cert": "auto", } suggested = { mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_PROTOCOL: "3.1.1", } for k, v in defaults.items(): assert get_default(result["data_schema"].schema, k) == v for k, v in suggested.items(): assert get_suggested(result["data_schema"].schema, k) == v + # test the client cert and key were migrated to the entry + assert config_entry.data[mqtt.CONF_CLIENT_CERT] == MOCK_CLIENT_CERT.decode( + "utf-8" + ) + assert config_entry.data[mqtt.CONF_CLIENT_KEY] == MOCK_CLIENT_KEY.decode( + "utf-8" + ) + assert config_entry.data[mqtt.CONF_CERTIFICATE] == "auto" + + # test we can chante username and password + # as it was configured as auto in configuration.yaml is is migrated now + mock_try_connection_success.reset_mock() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -784,24 +1191,135 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_PORT: 2345, mqtt.CONF_USERNAME: "us3r", mqtt.CONF_PASSWORD: "p4ss", + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: True, }, ) assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} assert result["step_id"] == "options" + await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( "us3r", "p4ss", ) - # check if tls_insecure_set is called assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # check if the certificate settings were set from configuration.yaml + # check if the ca certificate settings were not set during connection test assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ "certfile" - ] == str(certfile) + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ "keyfile" - ] == str(keyfile) + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) + + # Accept default option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + +async def test_setup_with_advanced_settings( + hass, mock_try_connection, tmp_path, mock_ssl_context, mock_process_uploaded_file +): + """Test config flow setup with advanced parameters.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mock_try_connection.return_value = True + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[mqtt.CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[mqtt.CONF_PROTOCOL] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: True, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[mqtt.CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[mqtt.CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + + # third iteration, advanced settings with client cert and key set + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: True, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "options" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", + }, + ) + assert result["type"] == "create_entry" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 90df45b65a1c5f..426ccb5806fa63 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1940,6 +1940,7 @@ async def test_update_incomplete_entry( # Config entry data should now be updated assert entry.data == { "port": 1234, + "discovery_prefix": "homeassistant", "broker": "yaml_broker", } # Warnings about broker deprecated, but not about other keys with default values @@ -2969,7 +2970,7 @@ async def test_remove_unknown_conf_entry_options(hass, mqtt_client_mock, caplog) mqtt_config_entry_data = { mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}, - mqtt.client.CONF_PROTOCOL: mqtt.const.PROTOCOL_311, + "old_option": "old_value", } entry = MockConfigEntry( @@ -2985,8 +2986,7 @@ async def test_remove_unknown_conf_entry_options(hass, mqtt_client_mock, caplog) assert mqtt.client.CONF_PROTOCOL not in entry.data assert ( "The following unsupported configuration options were removed from the " - "MQTT config entry: {'protocol'}. Add them to configuration.yaml if they " - "are needed" + "MQTT config entry: {'old_option'}" ) in caplog.text diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6cfaa9678bba3d..1884d04efc3073 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -313,6 +313,12 @@ async def test_setting_sensor_value_via_mqtt_json_message( assert state.state == "100" + # Make sure the state is written when a sensor value is reset to '' + async_fire_mqtt_message(hass, "test-topic", '{ "val": "" }') + state = hass.states.get("sensor.test") + + assert state.state == "" + async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( hass, mqtt_mock_entry_with_yaml_config diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py new file mode 100644 index 00000000000000..e7d75ee7cc8073 --- /dev/null +++ b/tests/components/mqtt/test_update.py @@ -0,0 +1,525 @@ +"""The tests for mqtt update component.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, update +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.setup import async_setup_component + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setup_manual_entity_from_yaml, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + update.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "latest_version_topic": "test-topic", + "command_topic": "test-topic", + "payload_install": "install", + } + } +} + + +@pytest.fixture(autouse=True) +def update_platform_only(): + """Only setup the update platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.UPDATE]): + yield + + +async def test_run_update_setup(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "name": "Test Update", + "release_summary": "Test release summary", + "release_url": "https://example.com/release", + "title": "Test Update Title", + "entity_picture": "https://example.com/icon.png", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert state.attributes.get("release_summary") == "Test release summary" + assert state.attributes.get("release_url") == "https://example.com/release" + assert state.attributes.get("title") == "Test Update Title" + assert state.attributes.get("entity_picture") == "https://example.com/icon.png" + + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "value_template": "{{ value_json.installed }}", + "latest_version_topic": latest_version_topic, + "latest_version_template": "{{ value_json.latest }}", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert ( + state.attributes.get("entity_picture") + == "https://brands.home-assistant.io/_/mqtt/icon.png" + ) + + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_empty_json_state_message(hass, mqtt_mock_entry_with_yaml_config): + """Test an empty JSON payload.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, state_topic, "{}") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_UNKNOWN + + +async def test_json_state_message(hass, mqtt_mock_entry_with_yaml_config): + """Test whether it fetches data from a JSON payload.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message( + hass, + state_topic, + '{"installed_version":"1.9.0","latest_version":"1.9.0",' + '"title":"Test Update Title","release_url":"https://example.com/release",' + '"release_summary":"Test release summary"}', + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert state.attributes.get("release_summary") == "Test release summary" + assert state.attributes.get("release_url") == "https://example.com/release" + assert state.attributes.get("title") == "Test Update Title" + + async_fire_mqtt_message( + hass, + state_topic, + '{"installed_version":"1.9.0","latest_version":"2.0.0","title":"Test Update Title"}', + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_json_state_message_with_template(hass, mqtt_mock_entry_with_yaml_config): + """Test whether it fetches data from a JSON payload with template.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "value_template": '{{ {"installed_version": value_json.installed, "latest_version": value_json.latest} | to_json }}', + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + + async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"2.0.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_run_install_service(hass, mqtt_mock_entry_with_yaml_config): + """Test that install service works.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + command_topic = "test/install-command" + + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "command_topic": command_topic, + "payload_install": "install", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_update"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(command_topic, "install", 0, False) + + +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): + """Test unique id option only creates one update per unique_id.""" + config = { + mqtt.DOMAIN: { + update.DOMAIN: [ + { + "name": "Bear", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, config + ) + + +async def test_discovery_removal_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test removal of discovered update.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][update.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data + ) + + +async def test_discovery_update_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered update.""" + config1 = { + "name": "Beer", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + config2 = { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass, mqtt_mock_entry_no_yaml_config, caplog +): + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "installed-topic", "latest_version_topic": "latest-topic"}' + with patch( + "homeassistant.components.mqtt.update.MqttUpdate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "state_topic": "installed-topic", "latest_version_topic": "latest-topic" }' + + await help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setup_manual_entity_from_yaml(hass): + """Test setup manual configured MQTT entity.""" + platform = update.DOMAIN + await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry(hass, mqtt_mock_entry_with_yaml_config, tmp_path): + """Test unloading the config entry.""" + domain = update.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + ) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py new file mode 100644 index 00000000000000..a8eaba0421f414 --- /dev/null +++ b/tests/components/mqtt/test_util.py @@ -0,0 +1,49 @@ +"""Test MQTT utils.""" + +from random import getrandbits +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt + + +@pytest.fixture(autouse=True) +def mock_temp_dir(): + """Mock the certificate temp directory.""" + with patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + "home-assistant-mqtt" + f"-{getrandbits(10):03x}", + ) as mocked_temp_dir: + yield mocked_temp_dir + + +@pytest.mark.parametrize( + "option,content,file_created", + [ + (mqtt.CONF_CERTIFICATE, "auto", False), + (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###", True), + (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###", True), + (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), + ], +) +async def test_async_create_certificate_temp_files( + hass, mock_temp_dir, option, content, file_created +): + """Test creating and reading certificate files.""" + config = {option: content} + await mqtt.util.async_create_certificate_temp_files(hass, config) + + file_path = mqtt.util.get_file_path(option) + assert bool(file_path) is file_created + assert ( + mqtt.util.migrate_certificate_file_to_content(file_path or content) == content + ) + + +async def test_reading_non_exitisting_certificate_file(): + """Test reading a non existing certificate file.""" + assert ( + mqtt.util.migrate_certificate_file_to_content("/home/file_not_exists") is None + ) diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 58258682d5b983..3a1b7b568723cb 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -21,7 +21,11 @@ TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from tests.common import MockConfigEntry @@ -124,7 +128,7 @@ async def test_distance_sensor( @pytest.mark.parametrize( "unit_system, unit", - [(METRIC_SYSTEM, TEMP_CELSIUS), (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT)], + [(METRIC_SYSTEM, TEMP_CELSIUS), (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT)], ) async def test_temperature_sensor( hass: HomeAssistant, diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index b6f278d4e94846..a6d11305599204 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -32,12 +32,30 @@ async def test_config_not_ready(hass): unique_id="aa:bb:cc:dd:ee:ff", data={"host": "10.10.2.3"}, ) + entry.add_to_hass(hass) with patch( "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=ApiError("API Error"), ): - entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_not_ready_while_checking_credentials(hass): + """Test for setup failure if the connection fails while checking credentials.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + side_effect=ApiError("API Error"), + ): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -50,12 +68,12 @@ async def test_config_auth_failed(hass): unique_id="aa:bb:cc:dd:ee:ff", data={"host": "10.10.2.3"}, ) + entry.add_to_hass(hass) with patch( "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Authorization has failed"), ): - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index b6992a5772fd63..ffe957a3e28ebb 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -34,7 +34,11 @@ HVACAction, HVACMode, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -1465,3 +1469,63 @@ async def test_thermostat_hvac_mode_failure( with pytest.raises(HomeAssistantError): await common.async_set_preset_mode(hass, PRESET_ECO) await hass.async_block_till_done() + + +async def test_thermostat_available( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): + """Test a thermostat that is available.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 28.0, + }, + "sdm.devices.traits.Connectivity": {"status": "ONLINE"}, + }, + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVACMode.COOL + + +async def test_thermostat_unavailable( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): + """Test a thermostat that is unavailable.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 28.0, + }, + "sdm.devices.traits.Connectivity": {"status": "OFFLINE"}, + }, + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == STATE_UNAVAILABLE diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor_sdm.py index d1a89317959d69..c3698cf4123c6b 100644 --- a/tests/components/nest/test_sensor_sdm.py +++ b/tests/components/nest/test_sensor_sdm.py @@ -20,6 +20,7 @@ ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -90,6 +91,58 @@ async def test_thermostat_device( assert device.identifiers == {("nest", DEVICE_ID)} +async def test_thermostat_device_available( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +): + """Test a thermostat with temperature and humidity sensors that is Online.""" + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + "sdm.devices.traits.Connectivity": {"status": "ONLINE"}, + } + ) + await setup_platform() + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == "25.1" + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is not None + assert humidity.state == "35" + + +async def test_thermostat_device_unavailable( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +): + """Test a thermostat with temperature and humidity sensors that is Offline.""" + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + "sdm.devices.traits.Connectivity": {"status": "OFFLINE"}, + } + ) + await setup_platform() + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == STATE_UNAVAILABLE + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is not None + assert humidity.state == STATE_UNAVAILABLE + + async def test_no_devices(hass: HomeAssistant, setup_platform: PlatformSetup): """Test no devices returned by the api.""" await setup_platform() diff --git a/tests/components/netatmo/fixtures/getstationsdata.json b/tests/components/netatmo/fixtures/getstationsdata.json index 822a4c11a5016a..10c3ca85e06c2c 100644 --- a/tests/components/netatmo/fixtures/getstationsdata.json +++ b/tests/components/netatmo/fixtures/getstationsdata.json @@ -114,7 +114,7 @@ "battery_percent": 79 }, { - "_id": "12:34:56:03:1b:e4", + "_id": "12:34:56:03:1b:e5", "type": "NAModule2", "module_name": "Garden", "data_type": ["Wind"], @@ -430,63 +430,203 @@ "modules": [] }, { - "_id": "12:34:56:58:c8:54", - "date_setup": 1605594014, - "last_setup": 1605594014, + "_id": "12:34:56:80:bb:26", + "station_name": "MYHOME (Palier)", + "date_setup": 1558709904, + "last_setup": 1558709904, "type": "NAMain", - "last_status_store": 1605878352, - "firmware": 178, - "wifi_status": 47, + "last_status_store": 1644582700, + "module_name": "Palier", + "firmware": 181, + "last_upgrade": 1558709906, + "wifi_status": 57, "reachable": true, "co2_calibrating": false, "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], "place": { - "altitude": 65, - "city": "Njurunda District", - "country": "SE", - "timezone": "Europe/Stockholm", - "location": [17.123456, 62.123456] + "altitude": 329, + "city": "Someplace", + "country": "FR", + "timezone": "Europe/Paris", + "location": [6.1234567, 46.123456] }, - "station_name": "Njurunda (Indoor)", - "home_id": "5fb36b9ec68fd10c6467ca65", - "home_name": "Njurunda", + "home_id": "91763b24c43d3e344f424e8b", + "home_name": "MYHOME", "dashboard_data": { - "time_utc": 1605878349, - "Temperature": 19.7, - "CO2": 993, - "Humidity": 40, - "Noise": 40, - "Pressure": 1015.6, - "AbsolutePressure": 1007.8, - "min_temp": 19.7, - "max_temp": 20.4, - "date_max_temp": 1605826917, - "date_min_temp": 1605873207, + "time_utc": 1644582694, + "Temperature": 21.1, + "CO2": 1339, + "Humidity": 45, + "Noise": 35, + "Pressure": 1026.8, + "AbsolutePressure": 974.5, + "min_temp": 21, + "max_temp": 21.8, + "date_max_temp": 1644534255, + "date_min_temp": 1644550420, "temp_trend": "stable", "pressure_trend": "up" }, "modules": [ { - "_id": "12:34:56:58:e6:38", + "_id": "12:34:56:80:1c:42", "type": "NAModule1", - "last_setup": 1605594034, + "module_name": "Outdoor", + "last_setup": 1558709954, "data_type": ["Temperature", "Humidity"], - "battery_percent": 100, + "battery_percent": 27, "reachable": true, "firmware": 50, - "last_message": 1605878347, - "last_seen": 1605878328, - "rf_status": 62, - "battery_vp": 6198, + "last_message": 1644582699, + "last_seen": 1644582699, + "rf_status": 68, + "battery_vp": 4678, "dashboard_data": { - "time_utc": 1605878328, - "Temperature": 0.6, - "Humidity": 77, - "min_temp": -2.1, - "max_temp": 1.5, - "date_max_temp": 1605865920, - "date_min_temp": 1605826904, - "temp_trend": "down" + "time_utc": 1644582648, + "Temperature": 9.4, + "Humidity": 57, + "min_temp": 6.7, + "max_temp": 9.8, + "date_max_temp": 1644534223, + "date_min_temp": 1644569369, + "temp_trend": "up" + } + }, + { + "_id": "12:34:56:80:c1:ea", + "type": "NAModule3", + "module_name": "Rain", + "last_setup": 1563734531, + "data_type": ["Rain"], + "battery_percent": 21, + "reachable": true, + "firmware": 12, + "last_message": 1644582699, + "last_seen": 1644582699, + "rf_status": 79, + "battery_vp": 4256, + "dashboard_data": { + "time_utc": 1644582686, + "Rain": 3.7, + "sum_rain_1": 0, + "sum_rain_24": 6.9 + } + }, + { + "_id": "12:34:56:80:44:92", + "type": "NAModule4", + "module_name": "Bedroom", + "last_setup": 1575915890, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 28, + "reachable": true, + "firmware": 51, + "last_message": 1644582699, + "last_seen": 1644582654, + "rf_status": 67, + "battery_vp": 4695, + "dashboard_data": { + "time_utc": 1644582654, + "Temperature": 19.3, + "CO2": 1076, + "Humidity": 53, + "min_temp": 19.2, + "max_temp": 19.7, + "date_max_temp": 1644534243, + "date_min_temp": 1644553418, + "temp_trend": "stable" + } + }, + { + "_id": "12:34:56:80:7e:18", + "type": "NAModule4", + "module_name": "Bathroom", + "last_setup": 1575915955, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 55, + "reachable": true, + "firmware": 51, + "last_message": 1644582699, + "last_seen": 1644582654, + "rf_status": 59, + "battery_vp": 5184, + "dashboard_data": { + "time_utc": 1644582654, + "Temperature": 19.4, + "CO2": 1930, + "Humidity": 55, + "min_temp": 19.4, + "max_temp": 21.8, + "date_max_temp": 1644534224, + "date_min_temp": 1644582039, + "temp_trend": "stable" + } + }, + { + "_id": "12:34:56:03:1b:e4", + "type": "NAModule2", + "module_name": "Garden", + "data_type": ["Wind"], + "last_setup": 1549193862, + "reachable": true, + "dashboard_data": { + "time_utc": 1559413170, + "WindStrength": 4, + "WindAngle": 217, + "GustStrength": 9, + "GustAngle": 206, + "max_wind_str": 21, + "max_wind_angle": 217, + "date_max_wind_str": 1559386669 + }, + "firmware": 19, + "last_message": 1559413177, + "last_seen": 1559413177, + "rf_status": 59, + "battery_vp": 5689, + "battery_percent": 85 + } + ] + }, + { + "_id": "00:11:22:2c:be:c8", + "station_name": "Zuhause (Kinderzimmer)", + "type": "NAMain", + "last_status_store": 1649146022, + "reachable": true, + "favorite": true, + "data_type": ["Pressure"], + "place": { + "altitude": 127, + "city": "Wiesbaden", + "country": "DE", + "timezone": "Europe/Berlin", + "location": [8.238054275512695, 50.07585525512695] + }, + "read_only": true, + "dashboard_data": { + "time_utc": 1649146022, + "Pressure": 1015.6, + "AbsolutePressure": 1000.4, + "pressure_trend": "stable" + }, + "modules": [ + { + "_id": "00:11:22:2c:ce:b6", + "type": "NAModule1", + "data_type": ["Temperature", "Humidity"], + "reachable": true, + "last_message": 1649146022, + "last_seen": 1649145996, + "dashboard_data": { + "time_utc": 1649145996, + "Temperature": 7.8, + "Humidity": 87, + "min_temp": 6.5, + "max_temp": 7.8, + "date_max_temp": 1649145996, + "date_min_temp": 1649118465, + "temp_trend": "up" } } ] diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index 93c04388f4c190..6b24a7f8f9d4ac 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -23,7 +23,6 @@ "12:34:56:00:f1:62", "12:34:56:10:f1:66", "12:34:56:00:e3:9b", - "12:34:56:00:86:99", "0009999992" ] }, @@ -39,12 +38,6 @@ "type": "kitchen", "module_ids": ["12:34:56:03:a0:ac"] }, - { - "id": "2940411588", - "name": "Child", - "type": "custom", - "module_ids": ["12:34:56:26:cc:01"] - }, { "id": "222452125", "name": "Bureau", @@ -76,6 +69,12 @@ "name": "Corridor", "type": "corridor", "module_ids": ["10:20:30:bd:b8:1e"] + }, + { + "id": "100007520", + "name": "Toilettes", + "type": "toilets", + "module_ids": ["00:11:22:33:00:11:45:fe"] } ], "modules": [ @@ -120,15 +119,29 @@ "name": "Hall", "setup_date": 1544828430, "room_id": "3688132631", - "reachable": true, "modules_bridged": ["12:34:56:00:86:99", "12:34:56:00:e3:9b"] }, { - "id": "12:34:56:00:a5:a4", + "id": "12:34:56:10:f1:66", + "type": "NDB", + "name": "Netatmo-Doorbell", + "setup_date": 1602691361, + "room_id": "3688132631", + "reachable": true, + "hk_device_id": "123456007df1", + "customer_id": "1000010", + "network_lock": false, + "quick_display_zone": 62 + }, + { + "id": "12:34:56:10:b9:0e", "type": "NOC", - "name": "Garden", - "setup_date": 1544828430, - "reachable": true + "name": "Front", + "setup_date": 1509290599, + "reachable": true, + "customer_id": "A00010", + "network_lock": false, + "use_pincode": false }, { "id": "12:34:56:20:f5:44", @@ -155,33 +168,6 @@ "room_id": "222452125", "bridge": "12:34:56:20:f5:44" }, - { - "id": "12:34:56:10:f1:66", - "type": "NDB", - "name": "Netatmo-Doorbell", - "setup_date": 1602691361, - "room_id": "3688132631", - "reachable": true, - "hk_device_id": "123456007df1", - "customer_id": "1000010", - "network_lock": false, - "quick_display_zone": 62 - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "setup_date": 1620479901, - "bridge": "12:34:56:00:f1:62", - "name": "Sirene in hall" - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "name": "Window Hall", - "setup_date": 1581177375, - "bridge": "12:34:56:00:f1:62", - "category": "window" - }, { "id": "12:34:56:30:d5:d4", "type": "NBG", @@ -199,16 +185,17 @@ "bridge": "12:34:56:30:d5:d4" }, { - "id": "12:34:56:37:11:ca", + "id": "12:34:56:80:bb:26", "type": "NAMain", - "name": "NetatmoIndoor", + "name": "Villa", "setup_date": 1419453350, + "room_id": "4122897288", "reachable": true, "modules_bridged": [ - "12:34:56:07:bb:3e", - "12:34:56:03:1b:e4", - "12:34:56:36:fc:de", - "12:34:56:05:51:20" + "12:34:56:80:44:92", + "12:34:56:80:7e:18", + "12:34:56:80:1c:42", + "12:34:56:80:c1:ea" ], "customer_id": "C00016", "hardware_version": 251, @@ -271,48 +258,46 @@ "module_offset": { "12:34:56:80:bb:26": { "a": 0.1 + }, + "03:00:00:03:1b:0e": { + "a": 0 } } }, { - "id": "12:34:56:36:fc:de", + "id": "12:34:56:80:1c:42", "type": "NAModule1", "name": "Outdoor", "setup_date": 1448565785, - "bridge": "12:34:56:37:11:ca" - }, - { - "id": "12:34:56:03:1b:e4", - "type": "NAModule2", - "name": "Garden", - "setup_date": 1543579864, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:05:51:20", + "id": "12:34:56:80:c1:ea", "type": "NAModule3", "name": "Rain", "setup_date": 1591770206, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:07:bb:3e", + "id": "12:34:56:80:44:92", "type": "NAModule4", "name": "Bedroom", "setup_date": 1484997703, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:26:68:92", - "type": "NHC", - "name": "Indoor", - "setup_date": 1571342643 + "id": "12:34:56:80:7e:18", + "type": "NAModule4", + "name": "Bathroom", + "setup_date": 1543579864, + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:26:cc:01", - "type": "BNS", - "name": "Child", - "setup_date": 1571634243 + "id": "12:34:56:03:1b:e4", + "type": "NAModule2", + "name": "Garden", + "setup_date": 1543579864, + "bridge": "12:34:56:80:bb:26" }, { "id": "12:34:56:80:60:40", @@ -324,7 +309,8 @@ "12:34:56:80:00:12:ac:f2", "12:34:56:80:00:c3:69:3c", "12:34:56:00:00:a1:4c:da", - "12:34:56:00:01:01:01:a1" + "12:34:56:00:01:01:01:a1", + "00:11:22:33:00:11:45:fe" ] }, { @@ -342,6 +328,21 @@ "setup_date": 1641841262, "bridge": "12:34:56:80:60:40" }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "name": "Window Hall", + "setup_date": 1581177375, + "bridge": "12:34:56:00:f1:62", + "category": "window" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "setup_date": 1620479901, + "bridge": "12:34:56:00:f1:62", + "name": "Sirene in hall" + }, { "id": "12:34:56:00:16:0e", "type": "NLE", @@ -440,6 +441,24 @@ "room_id": "100008999", "bridge": "12:34:56:80:60:40" }, + { + "id": "10:20:30:bd:b8:1e", + "type": "BNS", + "name": "Smarther", + "setup_date": 1638022197, + "room_id": "1002003001" + }, + { + "id": "00:11:22:33:00:11:45:fe", + "type": "NLF", + "on": false, + "brightness": 63, + "firmware_revision": 57, + "last_seen": 1657086939, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, { "id": "12:34:56:00:01:01:01:a1", "type": "NLFN", @@ -761,80 +780,13 @@ "therm_mode": "schedule" }, { - "id": "111111111111111111111401", - "name": "Home with no modules", - "altitude": 9, - "coordinates": [1.23456789, 50.0987654], - "country": "BE", - "timezone": "Europe/Brussels", - "rooms": [ - { - "id": "1111111401", - "name": "Livingroom", - "type": "livingroom" - } - ], - "temperature_control_mode": "heating", - "therm_mode": "away", - "therm_setpoint_default_duration": 120, - "cooling_mode": "schedule", - "schedules": [ - { - "away_temp": 14, - "hg_temp": 7, - "name": "Week", - "timetable": [ - { - "zone_id": 1, - "m_offset": 0 - }, - { - "zone_id": 6, - "m_offset": 420 - } - ], - "zones": [ - { - "type": 0, - "name": "Comfort", - "rooms_temp": [], - "id": 0, - "rooms": [] - }, - { - "type": 1, - "name": "Nacht", - "rooms_temp": [], - "id": 1, - "rooms": [] - }, - { - "type": 5, - "name": "Eco", - "rooms_temp": [], - "id": 4, - "rooms": [] - }, - { - "type": 4, - "name": "Tussenin", - "rooms_temp": [], - "id": 5, - "rooms": [] - }, - { - "type": 4, - "name": "Ochtend", - "rooms_temp": [], - "id": 6, - "rooms": [] - } - ], - "id": "700000000000000000000401", - "selected": true, - "type": "therm" - } - ] + "id": "91763b24c43d3e344f424e8c", + "altitude": 112, + "coordinates": [52.516263, 13.377726], + "country": "DE", + "timezone": "Europe/Berlin", + "therm_setpoint_default_duration": 180, + "therm_mode": "schedule" } ], "user": { @@ -845,6 +797,8 @@ "unit_pressure": 0, "unit_system": 0, "unit_wind": 0, + "all_linked": false, + "type": "netatmo", "id": "91763b24c43d3e344f424e8b" } }, diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 4cd5dceec3bbb7..736d70be11cde3 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -14,25 +14,6 @@ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,", "is_local": true }, - { - "type": "NOC", - "firmware_revision": 3002000, - "monitoring": "on", - "sd_status": 4, - "connection": "wifi", - "homekit_status": "upgradable", - "floodlight": "auto", - "timelapse_available": true, - "id": "12:34:56:00:a5:a4", - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,", - "is_local": false, - "network_lock": false, - "firmware_name": "3.2.0", - "wifi_strength": 62, - "alim_status": 2, - "locked": false, - "wifi_state": "high" - }, { "id": "12:34:56:00:fa:d0", "type": "NAPlug", @@ -46,6 +27,7 @@ "type": "NATherm1", "firmware_revision": 65, "rf_strength": 58, + "battery_level": 3793, "boiler_valve_comfort_boost": false, "boiler_status": false, "anticipating": false, @@ -58,6 +40,7 @@ "type": "NRV", "firmware_revision": 79, "rf_strength": 51, + "battery_level": 3025, "bridge": "12:34:56:00:fa:d0", "battery_state": "full" }, @@ -67,18 +50,10 @@ "type": "NRV", "firmware_revision": 79, "rf_strength": 59, + "battery_level": 3029, "bridge": "12:34:56:00:fa:d0", "battery_state": "full" }, - { - "id": "12:34:56:26:cc:01", - "type": "BNS", - "firmware_revision": 32, - "wifi_strength": 50, - "boiler_valve_comfort_boost": false, - "boiler_status": true, - "cooler_status": false - }, { "type": "NDB", "last_ftp_event": { @@ -100,6 +75,25 @@ "wifi_strength": 66, "wifi_state": "medium" }, + { + "type": "NOC", + "firmware_revision": 3002000, + "monitoring": "on", + "sd_status": 4, + "connection": "wifi", + "homekit_status": "upgradable", + "floodlight": "auto", + "timelapse_available": true, + "id": "12:34:56:10:b9:0e", + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,", + "is_local": false, + "network_lock": false, + "firmware_name": "3.2.0", + "wifi_strength": 62, + "alim_status": 2, + "locked": false, + "wifi_state": "high" + }, { "boiler_control": "onoff", "dhw_control": "none", @@ -264,629 +258,43 @@ "bridge": "12:34:56:80:60:40" }, { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, - "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, + "id": "10:20:30:bd:b8:1e", + "type": "BNS", + "firmware_revision": 32, + "wifi_strength": 49, "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true + "boiler_status": true, + "cooler_status": false }, { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, + "id": "00:11:22:33:00:11:45:fe", + "type": "NLF", + "on": false, + "brightness": 63, + "firmware_revision": 57, + "last_seen": 1657086939, "power": 0, "reachable": true, "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, + } + ], + "rooms": [ { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, + "id": "2746182631", "reachable": true, - "bridge": "12:34:56:80:60:40" + "therm_measured_temperature": 19.8, + "therm_setpoint_temperature": 12, + "therm_setpoint_mode": "away", + "therm_setpoint_start_time": 1559229567, + "therm_setpoint_end_time": 0 }, { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, + "id": "2940411577", "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, - "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true - }, - { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, - "power": 0, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, - "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, - "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true - }, - { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, - "power": 0, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, - "reachable": true, - "bridge": "12:34:56:80:60:40" - } - ], - "rooms": [ - { - "id": "2746182631", - "reachable": true, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "schedule", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0 - }, - { - "id": "2940411577", - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, + "therm_measured_temperature": 27, + "heating_power_request": 0, "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", + "therm_setpoint_mode": "hg", "therm_setpoint_start_time": 0, "therm_setpoint_end_time": 0, "anticipating": false, @@ -905,15 +313,15 @@ "open_window": false }, { - "id": "2940411588", + "id": "1002003001", "reachable": true, "anticipating": false, "heating_power_request": 0, "open_window": false, - "humidity": 68, - "therm_measured_temperature": 19.9, - "therm_setpoint_temperature": 21.5, - "therm_setpoint_start_time": 1647793285, + "humidity": 67, + "therm_measured_temperature": 22, + "therm_setpoint_temperature": 22, + "therm_setpoint_start_time": 1647462737, "therm_setpoint_end_time": null, "therm_setpoint_mode": "home" } diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json index d950c82a6a5ced..406e24bc1077dc 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json @@ -1,12 +1,20 @@ { "status": "ok", - "time_server": 1559292041, + "time_server": 1642952130, "body": { "home": { - "modules": [], - "rooms": [], - "id": "91763b24c43d3e344f424e8c", - "persons": [] + "persons": [ + { + "id": "abcdef12-1111-0000-0000-000111222333", + "last_seen": 1489050910, + "out_of_sight": true + }, + { + "id": "abcdef12-2222-0000-0000-000111222333", + "last_seen": 1489078776, + "out_of_sight": true + } + ] } } } diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 027b0907d50aa4..76397988187b49 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -33,7 +33,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): await hass.async_block_till_done() camera_entity_indoor = "camera.hall" - camera_entity_outdoor = "camera.garden" + camera_entity_outdoor = "camera.front" assert hass.states.get(camera_entity_indoor).state == "streaming" response = { "event_type": "off", @@ -59,8 +59,8 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "on", @@ -72,8 +72,8 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "auto", @@ -84,7 +84,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", } @@ -166,7 +166,7 @@ async def test_camera_image_vpn(hass, config_entry, requests_mock, netatmo_auth) uri = "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,," stream_uri = uri + "/live/files/high/index.m3u8" - camera_entity_indoor = "camera.garden" + camera_entity_indoor = "camera.front" cam = hass.states.get(camera_entity_indoor) assert cam is not None @@ -304,14 +304,14 @@ async def test_service_set_camera_light(hass, config_entry, netatmo_auth): await hass.async_block_till_done() data = { - "entity_id": "camera.garden", + "entity_id": "camera.front", "camera_light_mode": "on", } expected_data = { "modules": [ { - "id": "12:34:56:00:a5:a4", + "id": "12:34:56:10:b9:0e", "floodlight": "on", }, ], @@ -353,7 +353,6 @@ async def test_service_set_camera_light_invalid_type(hass, config_entry, netatmo assert excinfo.value.args == ("NACamera does not have a floodlight",) -@pytest.mark.skip async def test_camera_reconnect_webhook(hass, config_entry): """Test webhook event on camera reconnect.""" fake_post_hits = 0 @@ -406,7 +405,7 @@ async def fake_post(*args, **kwargs): dt.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() - assert fake_post_hits > calls + assert fake_post_hits >= calls async def test_webhook_person_event(hass, config_entry, netatmo_auth): @@ -472,7 +471,7 @@ async def fake_post_no_data(*args, **kwargs): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert fake_post_hits == 9 + assert fake_post_hits == 11 async def test_camera_image_raises_exception(hass, config_entry, requests_mock): diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index d37bab929e1518..afe85049f95d8d 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -36,8 +36,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 12 @@ -80,8 +79,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "heat" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 21 @@ -194,8 +192,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) @@ -213,8 +210,7 @@ async def test_service_preset_mode_frost_guard_thermostat( assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) # Test service setting the preset mode to "frost guard" @@ -269,8 +265,7 @@ async def test_service_preset_mode_frost_guard_thermostat( assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) @@ -286,8 +281,7 @@ async def test_service_preset_modes_thermostat(hass, config_entry, netatmo_auth) assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) # Test service setting the preset mode to "away" diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 187a89afeb65b2..65cc991ec67445 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -110,7 +110,7 @@ async def fake_post(*args, **kwargs): await hass.async_block_till_done() - assert fake_post_hits == 8 + assert fake_post_hits == 10 mock_impl.assert_called_once() mock_webhook.assert_called_once() diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index b1a5270745ce87..526fb2fe518b48 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -27,14 +27,14 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION) await hass.async_block_till_done() - light_entity = "light.garden" + light_entity = "light.front" assert hass.states.get(light_entity).state == "unavailable" # Trigger light mode change response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "on", @@ -46,7 +46,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) # Trigger light mode change with erroneous webhook data response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", } await simulate_webhook(hass, webhook_id, response) @@ -62,7 +62,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "auto"}]} + {"modules": [{"id": "12:34:56:10:b9:0e", "floodlight": "auto"}]} ) # Test turning light on @@ -75,7 +75,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "on"}]} + {"modules": [{"id": "12:34:56:10:b9:0e", "floodlight": "on"}]} ) diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index d3ea8fb8167a65..9ef5637231615c 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -16,12 +16,12 @@ async def test_weather_sensor(hass, config_entry, netatmo_auth): await hass.async_block_till_done() - prefix = "sensor.netatmoindoor_" + prefix = "sensor.parents_bedroom_" - assert hass.states.get(f"{prefix}temperature").state == "24.6" - assert hass.states.get(f"{prefix}humidity").state == "36" - assert hass.states.get(f"{prefix}co2").state == "749" - assert hass.states.get(f"{prefix}pressure").state == "1017.3" + assert hass.states.get(f"{prefix}temperature").state == "20.3" + assert hass.states.get(f"{prefix}humidity").state == "63" + assert hass.states.get(f"{prefix}co2").state == "494" + assert hass.states.get(f"{prefix}pressure").state == "1014.5" async def test_public_weather_sensor(hass, config_entry, netatmo_auth): @@ -104,25 +104,25 @@ async def test_process_health(health, expected): @pytest.mark.parametrize( "uid, name, expected", [ - ("12:34:56:37:11:ca-reachable", "mystation_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "mystation_yard_radio", "Full"), + ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), ( - "12:34:56:37:11:ca-wifi_status", - "mystation_wifi_strength", - "Full", + "12:34:56:80:bb:26-wifi_status", + "villa_wifi_strength", + "High", ), ( - "12:34:56:37:11:ca-temp_trend", - "mystation_temperature_trend", + "12:34:56:80:bb:26-temp_trend", + "villa_temperature_trend", "stable", ), ( - "12:34:56:37:11:ca-pressure_trend", - "netatmo_mystation_pressure_trend", - "down", + "12:34:56:80:bb:26-pressure_trend", + "villa_pressure_trend", + "up", ), - ("12:34:56:05:51:20-sum_rain_1", "netatmo_mystation_yard_rain_last_hour", "0"), - ("12:34:56:05:51:20-sum_rain_24", "netatmo_mystation_yard_rain_today", "0"), + ("12:34:56:80:c1:ea-sum_rain_1", "villa_rain_rain_last_hour", "0"), + ("12:34:56:80:c1:ea-sum_rain_24", "villa_rain_rain_today", "6.9"), ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "SW"), ( "12:34:56:03:1b:e4-windangle_value", diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 2647102ba5ae19..f7dc08c41bbf41 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Nibe Heat Pump config flow.""" import errno +from socket import gaierror from unittest.mock import Mock, patch from nibe.coil import Coil @@ -150,13 +151,13 @@ async def test_unexpected_exception(hass: HomeAssistant, mock_connection: Mock) assert result2["errors"] == {"base": "unknown"} -async def test_invalid_ip(hass: HomeAssistant, mock_connection: Mock) -> None: +async def test_invalid_host(hass: HomeAssistant, mock_connection: Mock) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_connection.return_value.read_coil.side_effect = Exception() + mock_connection.return_value.read_coil.side_effect = gaierror() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {**MOCK_FLOW_USERDATA, "ip_address": "abcd"} diff --git a/tests/components/nina/fixtures/sample_warning_details.json b/tests/components/nina/fixtures/sample_warning_details.json index f9da183c553b68..aa176b2199ec0c 100644 --- a/tests/components/nina/fixtures/sample_warning_details.json +++ b/tests/components/nina/fixtures/sample_warning_details.json @@ -157,5 +157,52 @@ ] } ] + }, + "biw.BIWAPP-69634": { + "identifier": "biw.BIWAPP-69634", + "sender": "CAP@biwapp.de", + "sent": "1999-08-07T10:59:00+02:00", + "status": "Actual", + "msgType": "Alert", + "scope": "Public", + "code": ["DVN:2", "BIWAPP"], + "info": [ + { + "language": "DE", + "category": ["Other"], + "event": "4", + "urgency": "Unknown", + "severity": "Minor", + "certainty": "Unknown", + "expires": "2002-08-07T10:59:00+02:00", + "headline": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt", + "description": "In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.
 
Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.
 
In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.
 
Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.
 
Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.
 
Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.
 
Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.", + "parameter": [ + { + "valueName": "sender_langname", + "value": "Landkreis Osterholz" + }, + { + "valueName": "PHGEM", + "value": "740+10,770,792,817,100001" + }, + { + "valueName": "GRID", + "value": "101346,101954+7,102566+9,103177+13,103774,103790+13,104387+1,104403+13,105000+1,105016+15,105612+2,105630+15,106225+2,106241+18,106838+2,106853+18,107451+1,107464+22,108064+9,108075+23,108677+34,109290+34,109903+35,110516+35,111129+35,111742+35,112355,112357+34,112971+33,113587+30,114200+30,114814,114818+26,115432,115436+22,116050+21,116669+15,117283+5,117290+7,117897+3,117904+6,500001" + } + ], + "area": [ + { + "areaDesc": "Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede", + "geocode": [ + { + "valueName": "AreaId", + "value": "0" + } + ] + } + ] + } + ] } } diff --git a/tests/components/nina/fixtures/sample_warnings.json b/tests/components/nina/fixtures/sample_warnings.json index 0a41611b7ee5c8..12d78b03ccec2b 100644 --- a/tests/components/nina/fixtures/sample_warnings.json +++ b/tests/components/nina/fixtures/sample_warnings.json @@ -40,5 +40,29 @@ "onset": "2021-11-01T05:20:00+01:00", "sent": "2021-10-11T05:20:00+01:00", "expires": "3021-11-22T05:19:00+01:00" + }, + { + "id": "biw.BIWAPP-69634", + "payload": { + "version": 2, + "type": "ALERT", + "id": "biw.BIWAPP-69634", + "hash": "fdbafb6b164f549ff60b9adfa5b1c707069cdd178bf55f025066f319451660ad", + "data": { + "headline": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt", + "provider": "BIWAPP", + "severity": "Minor", + "msgType": "Alert", + "area": { + "type": "GRID", + "data": "101346,101954+7,102566+9,103177+13,103774,103790+13,104387+1,104403+13,105000+1,105016+15,105612+2,105630+15,106225+2,106241+18,106838+2,106853+18,107451+1,107464+22,108064+9,108075+23,108677+34,109290+34,109903+35,110516+35,111129+35,111742+35,112355,112357+34,112971+33,113587+30,114200+30,114814,114818+26,115432,115436+22,116050+21,116669+15,117283+5,117290+7,117897+3,117904+6,500001" + } + } + }, + "i18nTitle": { + "de": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt" + }, + "sent": "1999-08-07T10:59:00+02:00", + "expires": "2002-08-07T10:59:00+02:00" } ] diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 77efdacf943151..7d87b9adc6475e 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -22,9 +22,9 @@ def client_fixture(data_bridge, data_sensor, data_task): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -38,19 +38,19 @@ def config_fixture(hass): } -@pytest.fixture(name="data_bridge", scope="session") +@pytest.fixture(name="data_bridge", scope="package") def data_bridge_fixture(): """Define bridge data.""" return json.loads(load_fixture("bridge_data.json", "notion")) -@pytest.fixture(name="data_sensor", scope="session") +@pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" return json.loads(load_fixture("sensor_data.json", "notion")) -@pytest.fixture(name="data_task", scope="session") +@pytest.fixture(name="data_task", scope="package") def data_task_fixture(): """Define task data.""" return json.loads(load_fixture("task_data.json", "notion")) @@ -65,9 +65,3 @@ async def setup_notion_fixture(hass, client, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@host.com" diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 39d2777462f48a..d8b5abcc781c1c 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -7,105 +7,120 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_notion): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "bridges": { - "12345": { - "id": 12345, - "name": None, - "mode": "home", - "hardware_id": "0x1234567890abcdef", - "hardware_revision": 4, - "firmware_version": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.0.1", - }, - "missing_at": None, - "created_at": "2019-04-30T01:43:50.497Z", - "updated_at": "2019-04-30T01:44:43.749Z", - "system_id": 12345, - "firmware": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.0.1", - }, - "links": {"system": 12345}, - } + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "notion", + "title": REDACTED, + "data": {"username": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, - "sensors": { - "123456": { - "id": 123456, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Bathroom Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x1234567890abcdef", - "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": REDACTED, - "encryption_key": True, - "installed_at": "2019-04-30T01:57:34.443Z", - "calibrated_at": "2019-04-30T01:57:35.651Z", - "last_reported_at": "2019-04-30T02:20:04.821Z", - "missing_at": None, - "updated_at": "2019-04-30T01:57:36.129Z", - "created_at": "2019-04-30T01:56:45.932Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -46, - "surface_type": None, + "data": { + "bridges": { + "12345": { + "id": 12345, + "name": None, + "mode": "home", + "hardware_id": REDACTED, + "hardware_revision": 4, + "firmware_version": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "missing_at": None, + "created_at": "2019-04-30T01:43:50.497Z", + "updated_at": "2019-04-30T01:44:43.749Z", + "system_id": 12345, + "firmware": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "links": {"system": 12345}, + } }, - "132462": { - "id": 132462, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Living Room Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x1234567890abcdef", - "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": REDACTED, - "encryption_key": True, - "installed_at": "2019-04-30T01:45:56.169Z", - "calibrated_at": "2019-04-30T01:46:06.256Z", - "last_reported_at": "2019-04-30T02:20:04.829Z", - "missing_at": None, - "updated_at": "2019-04-30T01:46:07.717Z", - "created_at": "2019-04-30T01:45:14.148Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -30, - "surface_type": None, + "sensors": { + "123456": { + "id": 123456, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": REDACTED}, + "last_bridge_hardware_id": REDACTED, + "name": "Bathroom Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": REDACTED, + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:57:34.443Z", + "calibrated_at": "2019-04-30T01:57:35.651Z", + "last_reported_at": "2019-04-30T02:20:04.821Z", + "missing_at": None, + "updated_at": "2019-04-30T01:57:36.129Z", + "created_at": "2019-04-30T01:56:45.932Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -46, + "surface_type": None, + }, + "132462": { + "id": 132462, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": REDACTED}, + "last_bridge_hardware_id": REDACTED, + "name": "Living Room Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": REDACTED, + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:45:56.169Z", + "calibrated_at": "2019-04-30T01:46:06.256Z", + "last_reported_at": "2019-04-30T02:20:04.829Z", + "missing_at": None, + "updated_at": "2019-04-30T01:46:07.717Z", + "created_at": "2019-04-30T01:45:14.148Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -30, + "surface_type": None, + }, }, - }, - "tasks": { - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "low_battery", - "sensor_data": [], - "status": { - "insights": { - "primary": { - "from_state": None, - "to_state": "high", - "data_received_at": "2020-11-17T18:40:27.024Z", - "origin": {}, + "tasks": { + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "low_battery", + "sensor_data": [], + "status": { + "insights": { + "primary": { + "from_state": None, + "to_state": "high", + "data_received_at": "2020-11-17T18:40:27.024Z", + "origin": {}, + } } - } - }, - "created_at": "2020-11-17T18:40:27.024Z", - "updated_at": "2020-11-17T18:40:27.033Z", - "sensor_id": 525993, - "model_version": "4.1", - "configuration": {}, - "links": {"sensor": 525993}, - } + }, + "created_at": "2020-11-17T18:40:27.024Z", + "updated_at": "2020-11-17T18:40:27.033Z", + "sensor_id": 525993, + "model_version": "4.1", + "configuration": {}, + "links": {"sensor": 525993}, + } + }, }, } diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 8d7f8a91ae8b80..98b30616952a83 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -25,7 +25,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import mock_restore_cache_with_extra_data @@ -435,7 +435,7 @@ async def test_deprecated_methods( "native_min_value, state_min_value, native_step, state_step", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, @@ -450,7 +450,7 @@ async def test_deprecated_methods( 3, ), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index f51d3933b5d4c3..9713dc81d85764 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test number registered attributes to be excluded.""" await async_setup_component( hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/nut/fixtures/CP1500PFCLCD.json b/tests/components/nut/fixtures/CP1500PFCLCD.json index 3a42a01b054495..f3121b147acc49 100644 --- a/tests/components/nut/fixtures/CP1500PFCLCD.json +++ b/tests/components/nut/fixtures/CP1500PFCLCD.json @@ -5,7 +5,7 @@ "driver.parameter.pollfreq": "30", "ups.beeper.status": "disabled", "input.voltage.nominal": "120", - "device.serial": "000000000000", + "device.serial": "000000000000 ", "ups.timer.shutdown": "-60", "input.voltage": "122.0", "ups.status": "OL", diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 78d395755222a6..9597618ccc8b56 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( EXPECTED_FORECAST_IMPERIAL, @@ -24,7 +24,7 @@ "units,result_observation,result_forecast", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, SENSOR_EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_FORECAST_IMPERIAL, ), diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 3f3e9a649f3ebd..b3a9a4bc9f15cd 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -15,7 +15,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( EXPECTED_FORECAST_IMPERIAL, @@ -34,7 +34,7 @@ "units,result_observation,result_forecast", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, WEATHER_EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_FORECAST_IMPERIAL, ), diff --git a/tests/components/octoprint/test_camera.py b/tests/components/octoprint/test_camera.py new file mode 100644 index 00000000000000..2badf1285ceafa --- /dev/null +++ b/tests/components/octoprint/test_camera.py @@ -0,0 +1,67 @@ +"""The tests for Octoptint camera module.""" + +from unittest.mock import patch + +from pyoctoprintapi import WebcamSettings + +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def test_camera(hass): + """Test the underlying camera.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=WebcamSettings( + base_url="http://fake-octoprint/", + raw={ + "streamUrl": "/webcam/?action=stream", + "snapshotUrl": "http://127.0.0.1:8080/?action=snapshot", + "webcamEnabled": True, + }, + ), + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is not None + assert entry.unique_id == "uuid" + + +async def test_camera_disabled(hass): + """Test that the camera does not load if there is not one configured.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=WebcamSettings( + base_url="http://fake-octoprint/", + raw={ + "streamUrl": "/webcam/?action=stream", + "snapshotUrl": "http://127.0.0.1:8080/?action=snapshot", + "webcamEnabled": False, + }, + ), + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is None + + +async def test_no_supported_camera(hass): + """Test that the camera does not load if there is not one configured.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=None, + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is None diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e9de98206d1a34..b4e6c5b06662a7 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -533,3 +533,55 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_reauth_form(hass): + """Test we get the form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "testuser", + "host": "1.1.1.1", + "name": "Printer", + "port": 81, + "ssl": True, + "path": "/", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "entry_id": entry.entry_id, + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == "form" + assert not result["errors"] + + with patch( + "pyoctoprintapi.OctoprintClient.request_app_key", return_value="test-key" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "testuser", + }, + ) + await hass.async_block_till_done() + assert result["type"] == "progress" + + with patch( + "homeassistant.components.octoprint.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 204eb6bf77291b..40d889185ddbaf 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -57,6 +57,19 @@ async def mock_supervisor_fixture(hass, aioclient_mock): """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value=True, diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index 32845aa8d2633a..2ddaf1987f8306 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -533,6 +533,270 @@ ) } +MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { + "456789": OncueDevice( + name="My Generator", + state="Off", + product_name="RDC 2.4", + hardware_version="319", + serial_number="SERIAL", + sensors={ + "Product": OncueSensor( + name="Product", + display_name="Controller Type", + value="--", + display_value="RDC 2.4", + unit=None, + ), + "FirmwareVersion": OncueSensor( + name="FirmwareVersion", + display_name="Current Firmware", + value="--", + display_value="2.0.6", + unit=None, + ), + "LatestFirmware": OncueSensor( + name="LatestFirmware", + display_name="Latest Firmware", + value="--", + display_value="2.0.6", + unit=None, + ), + "EngineSpeed": OncueSensor( + name="EngineSpeed", + display_name="Engine Speed", + value="--", + display_value="0 R/min", + unit="R/min", + ), + "EngineTargetSpeed": OncueSensor( + name="EngineTargetSpeed", + display_name="Engine Target Speed", + value="--", + display_value="0 R/min", + unit="R/min", + ), + "EngineOilPressure": OncueSensor( + name="EngineOilPressure", + display_name="Engine Oil Pressure", + value="--", + display_value="0 Psi", + unit="Psi", + ), + "EngineCoolantTemperature": OncueSensor( + name="EngineCoolantTemperature", + display_name="Engine Coolant Temperature", + value="--", + display_value="32 F", + unit="F", + ), + "BatteryVoltage": OncueSensor( + name="BatteryVoltage", + display_name="Battery Voltage", + value="0.0", + display_value="13.4 V", + unit="V", + ), + "LubeOilTemperature": OncueSensor( + name="LubeOilTemperature", + display_name="Lube Oil Temperature", + value="--", + display_value="32 F", + unit="F", + ), + "GensetControllerTemperature": OncueSensor( + name="GensetControllerTemperature", + display_name="Generator Controller Temperature", + value="--", + display_value="84.2 F", + unit="F", + ), + "EngineCompartmentTemperature": OncueSensor( + name="EngineCompartmentTemperature", + display_name="Engine Compartment Temperature", + value="--", + display_value="62.6 F", + unit="F", + ), + "GeneratorTrueTotalPower": OncueSensor( + name="GeneratorTrueTotalPower", + display_name="Generator True Total Power", + value="--", + display_value="0.0 W", + unit="W", + ), + "GeneratorTruePercentOfRatedPower": OncueSensor( + name="GeneratorTruePercentOfRatedPower", + display_name="Generator True Percent Of Rated Power", + value="--", + display_value="0 %", + unit="%", + ), + "GeneratorVoltageAB": OncueSensor( + name="GeneratorVoltageAB", + display_name="Generator Voltage AB", + value="--", + display_value="0.0 V", + unit="V", + ), + "GeneratorVoltageAverageLineToLine": OncueSensor( + name="GeneratorVoltageAverageLineToLine", + display_name="Generator Voltage Average Line To Line", + value="--", + display_value="0.0 V", + unit="V", + ), + "GeneratorCurrentAverage": OncueSensor( + name="GeneratorCurrentAverage", + display_name="Generator Current Average", + value="--", + display_value="0.0 A", + unit="A", + ), + "GeneratorFrequency": OncueSensor( + name="GeneratorFrequency", + display_name="Generator Frequency", + value="--", + display_value="0.0 Hz", + unit="Hz", + ), + "GensetSerialNumber": OncueSensor( + name="GensetSerialNumber", + display_name="Generator Serial Number", + value="--", + display_value="33FDGMFR0026", + unit=None, + ), + "GensetState": OncueSensor( + name="GensetState", + display_name="Generator State", + value="--", + display_value="Off", + unit=None, + ), + "GensetControllerSerialNumber": OncueSensor( + name="GensetControllerSerialNumber", + display_name="Generator Controller Serial Number", + value="--", + display_value="-1", + unit=None, + ), + "GensetModelNumberSelect": OncueSensor( + name="GensetModelNumberSelect", + display_name="Genset Model Number Select", + value="--", + display_value="38 RCLB", + unit=None, + ), + "GensetControllerClockTime": OncueSensor( + name="GensetControllerClockTime", + display_name="Generator Controller Clock Time", + value="--", + display_value="2022-01-13 18:08:13", + unit=None, + ), + "GensetControllerTotalOperationTime": OncueSensor( + name="GensetControllerTotalOperationTime", + display_name="Generator Controller Total Operation Time", + value="--", + display_value="16770.8 h", + unit="h", + ), + "EngineTotalRunTime": OncueSensor( + name="EngineTotalRunTime", + display_name="Engine Total Run Time", + value="--", + display_value="28.1 h", + unit="h", + ), + "EngineTotalRunTimeLoaded": OncueSensor( + name="EngineTotalRunTimeLoaded", + display_name="Engine Total Run Time Loaded", + value="--", + display_value="5.5 h", + unit="h", + ), + "EngineTotalNumberOfStarts": OncueSensor( + name="EngineTotalNumberOfStarts", + display_name="Engine Total Number Of Starts", + value="--", + display_value="101", + unit=None, + ), + "GensetTotalEnergy": OncueSensor( + name="GensetTotalEnergy", + display_name="Genset Total Energy", + value="--", + display_value="1.2022309E7 kWh", + unit="kWh", + ), + "AtsContactorPosition": OncueSensor( + name="AtsContactorPosition", + display_name="Ats Contactor Position", + value="--", + display_value="Source1", + unit=None, + ), + "AtsSourcesAvailable": OncueSensor( + name="AtsSourcesAvailable", + display_name="Ats Sources Available", + value="--", + display_value="Source1", + unit=None, + ), + "Source1VoltageAverageLineToLine": OncueSensor( + name="Source1VoltageAverageLineToLine", + display_name="Source1 Voltage Average Line To Line", + value="--", + display_value="253.5 V", + unit="V", + ), + "Source2VoltageAverageLineToLine": OncueSensor( + name="Source2VoltageAverageLineToLine", + display_name="Source2 Voltage Average Line To Line", + value="--", + display_value="0.0 V", + unit="V", + ), + "IPAddress": OncueSensor( + name="IPAddress", + display_name="IP Address", + value="--", + display_value="1.2.3.4:1026", + unit=None, + ), + "MacAddress": OncueSensor( + name="MacAddress", + display_name="Mac Address", + value="--", + display_value="--", + unit=None, + ), + "ConnectedServerIPAddress": OncueSensor( + name="ConnectedServerIPAddress", + display_name="Connected Server IP Address", + value="--", + display_value="40.117.195.28", + unit=None, + ), + "NetworkConnectionEstablished": OncueSensor( + name="NetworkConnectionEstablished", + display_name="Network Connection Established", + value="--", + display_value="True", + unit=None, + ), + "SerialNumber": OncueSensor( + name="SerialNumber", + display_name="Serial Number", + value="--", + display_value="1073879692", + unit=None, + ), + }, + ) +} + def _patch_login_and_data(): @contextmanager @@ -556,3 +820,28 @@ def _patcher(): yield return _patcher() + + +def _patch_login_and_data_unavailable(): + @contextmanager + def _patcher(): + with patch("homeassistant.components.oncue.Oncue.async_login"), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, + ): + yield + + return _patcher() + + +def _patch_login_and_data_unavailable_device(): + @contextmanager + def _patcher(): + + with patch("homeassistant.components.oncue.Oncue.async_login"), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py index 020b914c76bac0..f2e7657089f864 100644 --- a/tests/components/oncue/test_binary_sensor.py +++ b/tests/components/oncue/test_binary_sensor.py @@ -4,11 +4,11 @@ from homeassistant.components import oncue from homeassistant.components.oncue.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_ON +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_unavailable from tests.common import MockConfigEntry @@ -33,3 +33,25 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: ).state == STATE_ON ) + + +async def test_binary_sensors_not_unavailable(hass: HomeAssistant) -> None: + """Test the network connection established binary sensor is available when connection status is false.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data_unavailable(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("binary_sensor")) == 1 + assert ( + hass.states.get( + "binary_sensor.my_generator_network_connection_established" + ).state + == STATE_OFF + ) diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py index 60c9f68f81b2b8..6319bcdd9f9bc2 100644 --- a/tests/components/oncue/test_sensor.py +++ b/tests/components/oncue/test_sensor.py @@ -6,12 +6,17 @@ from homeassistant.components import oncue from homeassistant.components.oncue.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from . import _patch_login_and_data, _patch_login_and_data_offline_device +from . import ( + _patch_login_and_data, + _patch_login_and_data_offline_device, + _patch_login_and_data_unavailable, + _patch_login_and_data_unavailable_device, +) from tests.common import MockConfigEntry @@ -141,3 +146,159 @@ async def test_sensors(hass: HomeAssistant, patcher, connections) -> None: assert ( hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" ) + + +@pytest.mark.parametrize( + "patcher, connections", + [ + [_patch_login_and_data_unavailable_device, set()], + [_patch_login_and_data_unavailable, {("mac", "c9:24:22:6f:14:00")}], + ], +) +async def test_sensors_unavailable(hass: HomeAssistant, patcher, connections) -> None: + """Test that the sensors are unavailable.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patcher(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("sensor")) == 25 + assert ( + hass.states.get("sensor.my_generator_latest_firmware").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_speed").state == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_oil_pressure").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_coolant_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_battery_voltage").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_lube_oil_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_controller_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_compartment_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_true_total_power").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_true_percent_of_rated_power" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_frequency").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_state").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_controller_total_operation_time" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_total_run_time").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_ats_contactor_position").state + == STATE_UNAVAILABLE + ) + + assert hass.states.get("sensor.my_generator_ip_address").state == STATE_UNAVAILABLE + + assert ( + hass.states.get("sensor.my_generator_connected_server_ip_address").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_target_speed").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_source1_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_source2_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_genset_total_energy").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("sensor.my_generator_engine_total_number_of_starts").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("sensor.my_generator_generator_current_average").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_battery_voltage").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/openexchangerates/test_config_flow.py b/tests/components/openexchangerates/test_config_flow.py index ee4ba57de2c9e0..213badcab08718 100644 --- a/tests/components/openexchangerates/test_config_flow.py +++ b/tests/components/openexchangerates/test_config_flow.py @@ -237,32 +237,3 @@ async def test_reauth( assert result["type"] == "abort" assert result["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_create_entry( - hass: HomeAssistant, - mock_latest_rates_config_flow: AsyncMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test we can import data from configuration.yaml.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "api_key": "test-api-key", - "base": "USD", - "quote": "EUR", - "name": "test", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "USD" - assert result["data"] == { - "api_key": "test-api-key", - "base": "USD", - "quote": "EUR", - "name": "test", - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index c39a84b8b4c376..3caa41749ee53f 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -17,11 +17,11 @@ @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=unique_id, + unique_id=f"{config[CONF_LATITUDE]}, {config[CONF_LONGITUDE]}", data=config, options={CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 3.5}, ) @@ -40,13 +40,13 @@ def config_fixture(hass): } -@pytest.fixture(name="data_protection_window", scope="session") +@pytest.fixture(name="data_protection_window", scope="package") def data_protection_window_fixture(): """Define a fixture to return UV protection window data.""" return json.loads(load_fixture("protection_window_data.json", "openuv")) -@pytest.fixture(name="data_uv_index", scope="session") +@pytest.fixture(name="data_uv_index", scope="package") def data_uv_index_fixture(): """Define a fixture to return UV index data.""" return json.loads(load_fixture("uv_index_data.json", "openuv")) @@ -68,9 +68,3 @@ async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_ind assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "51.528308, -0.3817765" diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 1196045300b3d2..84e8a691255b6d 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -1,29 +1,39 @@ """Test OpenUV diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.openuv import CONF_ENTRY_ID +from homeassistant.setup import async_setup_component from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): """Test config entry diagnostics.""" - await hass.services.async_call( - "openuv", "update_data", service_data={CONF_ENTRY_ID: "test_entry_id"} - ) + await async_setup_component(hass, "homeassistant", {}) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "openuv", + "title": REDACTED, "data": { "api_key": REDACTED, "elevation": 0, "latitude": REDACTED, "longitude": REDACTED, }, - "options": { - "from_window": 3.5, - "to_window": 3.5, - }, + "options": {"from_window": 3.5, "to_window": 3.5}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { + "protection_window": { + "from_time": "2018-07-30T15:17:49.750Z", + "from_uv": 3.2509, + "to_time": "2018-07-30T22:47:49.750Z", + "to_uv": 3.6483, + }, "uv": { "uv": 8.2342, "uv_time": "2018-07-30T20:53:06.302Z", @@ -62,11 +72,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): }, }, }, - "protection_window": { - "from_time": "2018-07-30T15:17:49.750Z", - "from_uv": 3.2509, - "to_time": "2018-07-30T22:47:49.750Z", - "to_uv": 3.6483, - }, }, } diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py new file mode 100644 index 00000000000000..5525a859f21c80 --- /dev/null +++ b/tests/components/oralb/__init__.py @@ -0,0 +1,35 @@ +"""Tests for the OralB integration.""" + + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +NOT_ORALB_SERVICE_INFO = BluetoothServiceInfo( + name="Not it", + address="61DE521B-F0BF-9F44-64D4-75BBE1738105", + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +ORALB_SERVICE_INFO = BluetoothServiceInfo( + name="78:DB:2F:C2:48:BE", + address="78:DB:2F:C2:48:BE", + rssi=-63, + manufacturer_data={220: b"\x02\x01\x08\x03\x00\x00\x00\x01\x01\x00\x04"}, + service_uuids=[], + service_data={}, + source="local", +) + + +ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( + name="GXB772CD\x00\x00\x00\x00\x00\x00\x00\x00\x00", + address="78:DB:2F:C2:48:BE", + rssi=-63, + manufacturer_data={220: b"\x074\x0c\x038\x00\x00\x02\x01\x00\x04"}, + service_uuids=[], + service_data={}, + source="local", +) diff --git a/tests/components/oralb/conftest.py b/tests/components/oralb/conftest.py new file mode 100644 index 00000000000000..454cb7af726a5f --- /dev/null +++ b/tests/components/oralb/conftest.py @@ -0,0 +1,8 @@ +"""OralB session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/oralb/test_config_flow.py b/tests/components/oralb/test_config_flow.py new file mode 100644 index 00000000000000..cb7f97a50898c8 --- /dev/null +++ b/tests/components/oralb/test_config_flow.py @@ -0,0 +1,211 @@ +"""Test the OralB config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.oralb.const import DOMAIN +from homeassistant.data_entry_flow import FlowResultType + +from . import NOT_ORALB_SERVICE_INFO, ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_SERVICE_INFO + +from tests.common import MockConfigEntry + + +async def test_async_step_bluetooth_valid_device(hass): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_bluetooth_valid_io_series4_device(hass): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_IO_SERIES_4_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "IO Series 4 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_bluetooth_not_oralb(hass): + """Test discovery via bluetooth not oralb.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_async_step_user_no_devices_found(hass): + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass): + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_user_device_added_between_steps(hass): + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup(hass): + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass): + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass): + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_async_step_user_takes_precedence_over_discovery(hass): + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + # Verify the original one was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py new file mode 100644 index 00000000000000..2122ad9bbff803 --- /dev/null +++ b/tests/components/oralb/test_sensor.py @@ -0,0 +1,65 @@ +"""Test the OralB sensors.""" + + +from homeassistant.components.oralb.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME + +from . import ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_sensors(hass, entity_registry_enabled_by_default): + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, ORALB_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 8 + + toothbrush_sensor = hass.states.get( + "sensor.smart_series_7000_48be_toothbrush_state" + ) + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor.state == "running" + assert ( + toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] + == "Smart Series 7000 48BE Toothbrush State" + ) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_io_series_4(hass, entity_registry_enabled_by_default): + """Test setting up creates the sensors with an io series 4.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_IO_SERIES_4_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, ORALB_IO_SERIES_4_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 8 + + toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_mode") + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Mode" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 940da7b39c2d67..dc50896626d0ce 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -27,6 +27,7 @@ TEST_PASSWORD2 = "test-password2" TEST_HUB = "somfy_europe" TEST_HUB2 = "hi_kumo_europe" +TEST_HUB_COZYTOUCH = "atlantic_cozytouch" TEST_GATEWAY_ID = "1234-5678-9123" TEST_GATEWAY_ID2 = "4321-5678-9123" @@ -89,7 +90,7 @@ async def test_form(hass: HomeAssistant) -> None: (ClientError, "cannot_connect"), (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), - (UnknownUserException, "unknown_user"), + (UnknownUserException, "unsupported_hardware"), (Exception, "unknown"), ], ) @@ -112,6 +113,35 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": error} +@pytest.mark.parametrize( + "side_effect, error", + [ + (BadCredentialsException, "unsupported_hardware"), + ], +) +async def test_form_invalid_cozytouch_auth( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth from CozyTouch.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB_COZYTOUCH, + }, + ) + + assert result["step_id"] == config_entries.SOURCE_USER + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": error} + + async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: """Test config flow aborts Config Flow on duplicate entries.""" MockConfigEntry( diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index 40d2930ce93911..d143bbef00df42 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -144,7 +144,7 @@ async def test_state_problem_if_unavailable(hass): assert state.attributes[plant.READING_MOISTURE] == STATE_UNAVAILABLE -async def test_load_from_db(hass, recorder_mock): +async def test_load_from_db(recorder_mock, hass): """Test bootstrapping the brightness history from the database. This test can should only be executed if the loading of the history diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index fb5a0f067242c9..8c25baa8746aea 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -766,6 +766,62 @@ async def test_trigger_reauth( assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" +async def test_trigger_reauth_multiple_servers_available( + hass, + entry, + mock_plex_server, + mock_websocket, + current_request_with_host, + requests_mock, + plextv_resources_two_servers, +): + """Test setup and reauthorization of a Plex token when multiple servers are available.""" + assert entry.state is ConfigEntryState.LOADED + + requests_mock.get( + "https://plex.tv/api/resources", + text=plextv_resources_two_servers, + ) + + with patch( + "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized + ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): + trigger_plex_update(mock_websocket) + await wait_for_debouncer(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is not ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + flow_id = flows[0]["flow_id"] + + with patch("plexauth.PlexAuth.initiate_auth"), patch( + "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" + ): + result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) + assert result["type"] == "external" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "external_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "abort" + assert result["flow_id"] == flow_id + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.flow.async_progress()) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert entry.state is ConfigEntryState.LOADED + assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name + assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL + assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + + async def test_client_request_missing(hass): """Test when client headers are not set properly.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index d7941f744509fd..aa34fc8bed6760 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -63,6 +63,7 @@ def mock_smile_config_flow() -> Generator[None, MagicMock, None]: ) as smile_mock: smile = smile_mock.return_value smile.smile_hostname = "smile12345" + smile.smile_model = "Test Model" smile.smile_name = "Test Smile Name" smile.connect.return_value = True yield smile @@ -83,6 +84,7 @@ def mock_smile_adam() -> Generator[None, MagicMock, None]: smile.smile_version = "3.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -108,6 +110,7 @@ def mock_smile_adam_2() -> Generator[None, MagicMock, None]: smile.smile_version = "3.6.4" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -133,6 +136,7 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: smile.smile_version = "3.6.4" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -157,7 +161,8 @@ def mock_smile_anna() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -181,7 +186,8 @@ def mock_smile_anna_2() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -205,7 +211,8 @@ def mock_smile_anna_3() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -229,6 +236,7 @@ def mock_smile_p1() -> Generator[None, MagicMock, None]: smile.smile_version = "3.3.9" smile.smile_type = "power" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Smile P1" smile.connect.return_value = True @@ -253,6 +261,7 @@ def mock_stretch() -> Generator[None, MagicMock, None]: smile.smile_version = "3.1.11" smile.smile_type = "stretch" smile.smile_hostname = "stretch98765" + smile.smile_model = "Gateway" smile.smile_name = "Stretch" smile.connect.return_value = True diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 08792347af09ec..d62ff0e249d8b1 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -26,7 +26,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -53,6 +54,7 @@ "name": "Floor kraan", "zigbee_mac_address": "ABCD012345670A02", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 26.0, "setpoint": 21.5, @@ -69,6 +71,7 @@ "name": "Bios Cv Thermostatic Radiator ", "zigbee_mac_address": "ABCD012345670A09", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 17.2, "setpoint": 13.0, @@ -92,7 +95,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": [ "CV Roan", @@ -116,12 +120,11 @@ "hardware": "AME Smile 2.0 board", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", - "regulation_modes": [], "binary_sensors": { "plugwise_notification": true }, @@ -138,6 +141,7 @@ "name": "Thermostatic Radiator Jessie", "zigbee_mac_address": "ABCD012345670A10", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 17.1, "setpoint": 15.0, @@ -154,6 +158,7 @@ "name": "Playstation Smart Plug", "zigbee_mac_address": "ABCD012345670A12", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, @@ -173,6 +178,7 @@ "name": "CV Pomp", "zigbee_mac_address": "ABCD012345670A05", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -205,6 +211,7 @@ "name": "NAS", "zigbee_mac_address": "ABCD012345670A14", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 16.5, "electricity_consumed_interval": 0.5, @@ -224,6 +231,7 @@ "name": "USG Smart Plug", "zigbee_mac_address": "ABCD012345670A16", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 8.5, "electricity_consumed_interval": 0.0, @@ -243,6 +251,7 @@ "name": "NVR", "zigbee_mac_address": "ABCD012345670A15", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 34.0, "electricity_consumed_interval": 9.15, @@ -262,6 +271,7 @@ "name": "Fibaro HC2", "zigbee_mac_address": "ABCD012345670A13", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, @@ -288,7 +298,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": [ "CV Roan", @@ -315,6 +326,7 @@ "name": "Thermostatic Radiator Badkamer", "zigbee_mac_address": "ABCD012345670A17", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 19.1, "setpoint": 14.0, @@ -338,7 +350,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -364,6 +377,7 @@ "name": "Ziggo Modem", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 12.2, "electricity_consumed_interval": 2.97, @@ -390,7 +404,8 @@ "upper_bound": 100.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "no_frost", "available_schedules": [ "CV Roan", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 1cc94ca6347b1e..546a11b2c68df3 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": true, @@ -30,6 +30,7 @@ }, "sensors": { "water_temperature": 29.1, + "dhw_temperature": 46.3, "intended_boiler_temperature": 0.0, "modulation_level": 52, "return_temperature": 25.1, @@ -46,9 +47,9 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 20.5, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -78,10 +81,11 @@ "mode": "auto", "sensors": { "temperature": 19.3, - "setpoint": 20.5, "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 1f7c82983d42a8..06a3fa400bfe95 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -10,23 +10,29 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "thermostat": { - "setpoint": 18.5, + "setpoint_low": 4.0, + "setpoint_high": 23.5, "lower_bound": 1.0, "upper_bound": 35.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "None", "last_used": "Weekschema", "control_state": "cooling", - "mode": "cool", - "sensors": { "temperature": 18.1, "setpoint": 18.5 } + "mode": "heat_cool", + "sensors": { + "temperature": 25.8, + "setpoint_low": 4.0, + "setpoint_high": 23.5 + } }, "1772a4ea304041adb83f357b751341ff": { "dev_class": "thermo_sensor", @@ -37,6 +43,7 @@ "name": "Tom Badkamer", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 21.6, "battery": 99, @@ -54,12 +61,14 @@ "zigbee_mac_address": "ABCD012345670A04", "vendor": "Plugwise", "thermostat": { - "setpoint": 15.0, + "setpoint_low": 19.0, + "setpoint_high": 25.0, "lower_bound": 0.0, "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "Badkamer", @@ -67,9 +76,10 @@ "control_state": "off", "mode": "auto", "sensors": { - "temperature": 17.9, + "temperature": 239, "battery": 56, - "setpoint": 15.0 + "setpoint_low": 20.0, + "setpoint_high": 23.5 } }, "da224107914542988a88561b4452b0f6": { @@ -78,10 +88,10 @@ "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "cooling", "regulation_modes": [ "cooling", @@ -94,7 +104,7 @@ "plugwise_notification": false }, "sensors": { - "outdoor_temperature": -1.25 + "outdoor_temperature": 29.65 } }, "056ee145a816487eaa69243c3280f8bf": { @@ -108,7 +118,7 @@ "upper_bound": 95.0, "resolution": 0.01 }, - "adam_cooling_enabled": true, + "available": true, "binary_sensors": { "cooling_state": true, "dhw_state": false, @@ -116,8 +126,8 @@ "flame_state": false }, "sensors": { - "water_temperature": 37.0, - "intended_boiler_temperature": 38.1 + "water_temperature": 19.0, + "intended_boiler_temperature": 17.5 }, "switches": { "dhw_cm_switch": false diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 0a00a5b7b1cdaf..8ee3df544e5b30 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -10,23 +10,24 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "thermostat": { - "setpoint": 18.5, + "setpoint": 20.0, "lower_bound": 1.0, "upper_bound": 35.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "None", "last_used": "Weekschema", "control_state": "heating", "mode": "heat", - "sensors": { "temperature": 18.1, "setpoint": 18.5 } + "sensors": { "temperature": 19.1, "setpoint": 20.0 } }, "1772a4ea304041adb83f357b751341ff": { "dev_class": "thermo_sensor", @@ -37,8 +38,9 @@ "name": "Tom Badkamer", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { - "temperature": 21.6, + "temperature": 18.6, "battery": 99, "temperature_difference": 2.3, "valve_position": 0.0 @@ -59,7 +61,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "Badkamer", @@ -78,10 +81,10 @@ "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], "binary_sensors": { @@ -108,6 +111,7 @@ "upper_bound": 60.0, "resolution": 0.01 }, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": true, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index a9a92126265230..6326a02fedb3a7 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": false, @@ -30,6 +30,7 @@ }, "sensors": { "water_temperature": 29.1, + "dhw_temperature": 46.3, "intended_boiler_temperature": 0.0, "modulation_level": 52, "return_temperature": 25.1, @@ -46,9 +47,9 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 24.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -78,10 +81,11 @@ "mode": "auto", "sensors": { "temperature": 26.3, - "setpoint": 24.0, "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 0c1fef1a171d0e..cd2747f423b462 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": false, @@ -29,12 +29,13 @@ "flame_state": false }, "sensors": { - "water_temperature": 29.1, - "intended_boiler_temperature": 0.0, - "modulation_level": 52, - "return_temperature": 25.1, + "water_temperature": 19.1, + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "return_temperature": 22.0, "water_pressure": 1.57, - "outdoor_air_temperature": 3.0 + "outdoor_air_temperature": 28.2 }, "switches": { "dhw_cm_switch": false @@ -46,14 +47,14 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, "sensors": { - "outdoor_temperature": 20.2 + "outdoor_temperature": 28.2 } }, "3cb70739631c4d17a86b8b12e8a5161b": { @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 20.5, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -77,11 +80,12 @@ "last_used": "standaard", "mode": "auto", "sensors": { - "temperature": 21.3, - "setpoint": 20.5, + "temperature": 23.0, "illuminance": 86.0, - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_activation_outdoor_temperature": 25.0, + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index fbf5aa63a5ff3d..c52f33e63234e3 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -1,19 +1,30 @@ [ { - "smile_name": "P1", - "gateway_id": "e950c7d5e1ee407a858e2a8b5016c8b3", + "smile_name": "Smile P1", + "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", "notifications": {} }, { - "e950c7d5e1ee407a858e2a8b5016c8b3": { + "cd3e822288064775a7c4afcdd70bdda2": { "dev_class": "gateway", "firmware": "3.3.9", "hardware": "AME Smile 2.0 board", "location": "cd3e822288064775a7c4afcdd70bdda2", "mac_address": "012345670001", - "model": "P1", + "model": "Gateway", + "name": "Smile P1", + "vendor": "Plugwise", + "binary_sensors": { + "plugwise_notification": false + } + }, + "e950c7d5e1ee407a858e2a8b5016c8b3": { + "dev_class": "smartmeter", + "location": "cd3e822288064775a7c4afcdd70bdda2", + "model": "2M550E-1012", "name": "P1", - "vendor": "Plugwise B.V.", + "vendor": "ISKRAEMECO", + "available": true, "sensors": { "net_electricity_point": -2816, "electricity_consumed_peak_point": 0, diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 1ff62e9e619b73..1ce34e376d719b 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -10,10 +10,10 @@ "firmware": "3.1.11", "location": "0000aaaa0000aaaa0000aaaa0000aa00", "mac_address": "01:23:45:67:89:AB", - "model": "Stretch", + "model": "Gateway", "name": "Stretch", - "vendor": "Plugwise B.V.", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "ABCD012345670101", + "vendor": "Plugwise" }, "5871317346d045bc9f6b987ef25ee638": { "dev_class": "water_heater_vessel", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index bcca1a32abbefc..ad5443a678c2e7 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -75,11 +75,10 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.anna") assert state - assert state.state == HVACMode.COOL + assert state.state == HVACMode.HEAT_COOL assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -133,7 +132,7 @@ async def test_adam_climate_entity_climate_changes( assert mock_smile_adam.set_temperature.call_count == 1 mock_smile_adam.set_temperature.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", 25.0 + "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} ) with pytest.raises(ValueError): @@ -165,7 +164,7 @@ async def test_adam_climate_entity_climate_changes( assert mock_smile_adam.set_temperature.call_count == 2 mock_smile_adam.set_temperature.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", 25.0 + "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} ) await hass.services.async_call( @@ -203,8 +202,7 @@ async def test_anna_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -213,8 +211,9 @@ async def test_anna_climate_entity_attributes( assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 20.5 + assert state.attributes["supported_features"] == 18 + assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_low"] == 20.5 assert state.attributes["min_temp"] == 4.0 assert state.attributes["max_temp"] == 30.0 assert state.attributes["target_temp_step"] == 0.1 @@ -231,12 +230,12 @@ async def test_anna_2_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] - assert state.attributes["temperature"] == 24.0 - assert state.attributes["supported_features"] == 17 + assert state.attributes["supported_features"] == 18 + assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_low"] == 20.5 async def test_anna_3_climate_entity_attributes( @@ -250,8 +249,7 @@ async def test_anna_3_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -263,14 +261,14 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_temperature", - {"entity_id": "climate.anna", "temperature": 25}, + {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", - 25.0, + {"setpoint_high": 25.0, "setpoint_low": 20.0}, ) await hass.services.async_call( @@ -288,7 +286,7 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, blocking=True, ) diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 3e3b2259e15e3b..7e8d574d5bd7a1 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -46,7 +46,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -69,6 +70,7 @@ async def test_diagnostics( "name": "Floor kraan", "zigbee_mac_address": "ABCD012345670A02", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 26.0, "setpoint": 21.5, @@ -85,6 +87,7 @@ async def test_diagnostics( "name": "Bios Cv Thermostatic Radiator ", "zigbee_mac_address": "ABCD012345670A09", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 17.2, "setpoint": 13.0, @@ -108,7 +111,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": [ "CV Roan", @@ -128,12 +132,11 @@ async def test_diagnostics( "hardware": "AME Smile 2.0 board", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", - "regulation_modes": [], "binary_sensors": {"plugwise_notification": True}, "sensors": {"outdoor_temperature": 7.81}, }, @@ -146,6 +149,7 @@ async def test_diagnostics( "name": "Thermostatic Radiator Jessie", "zigbee_mac_address": "ABCD012345670A10", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 17.1, "setpoint": 15.0, @@ -162,6 +166,7 @@ async def test_diagnostics( "name": "Playstation Smart Plug", "zigbee_mac_address": "ABCD012345670A12", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, @@ -178,6 +183,7 @@ async def test_diagnostics( "name": "CV Pomp", "zigbee_mac_address": "ABCD012345670A05", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -206,6 +212,7 @@ async def test_diagnostics( "name": "NAS", "zigbee_mac_address": "ABCD012345670A14", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 16.5, "electricity_consumed_interval": 0.5, @@ -222,6 +229,7 @@ async def test_diagnostics( "name": "USG Smart Plug", "zigbee_mac_address": "ABCD012345670A16", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 8.5, "electricity_consumed_interval": 0.0, @@ -238,6 +246,7 @@ async def test_diagnostics( "name": "NVR", "zigbee_mac_address": "ABCD012345670A15", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 34.0, "electricity_consumed_interval": 9.15, @@ -254,6 +263,7 @@ async def test_diagnostics( "name": "Fibaro HC2", "zigbee_mac_address": "ABCD012345670A13", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, @@ -277,7 +287,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": [ "CV Roan", @@ -300,6 +311,7 @@ async def test_diagnostics( "name": "Thermostatic Radiator Badkamer", "zigbee_mac_address": "ABCD012345670A17", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 19.1, "setpoint": 14.0, @@ -323,7 +335,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -345,6 +358,7 @@ async def test_diagnostics( "name": "Ziggo Modem", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 12.2, "electricity_consumed_interval": 2.97, @@ -368,7 +382,8 @@ async def test_diagnostics( "upper_bound": 100.0, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "no_frost", "available_schedules": [ "CV Roan", diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 2b4baccc7c9cce..9039c5a476e2fe 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -47,6 +47,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert state assert float(state.state) == 29.1 + state = hass.states.get("sensor.opentherm_dhw_temperature") + assert state + assert float(state.state) == 46.3 + state = hass.states.get("sensor.anna_illuminance") assert state assert float(state.state) == 86.0 diff --git a/tests/components/pushover/test_config_flow.py b/tests/components/pushover/test_config_flow.py index 1e919167c6a1d4..642f1b1b1bbae4 100644 --- a/tests/components/pushover/test_config_flow.py +++ b/tests/components/pushover/test_config_flow.py @@ -140,19 +140,6 @@ async def test_flow_conn_err(hass: HomeAssistant, mock_pushover: MagicMock) -> N assert result["errors"] == {"base": "cannot_connect"} -async def test_import(hass: HomeAssistant) -> None: - """Test user initialized flow with unreachable server.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Pushover" - assert result["data"] == MOCK_CONFIG - - async def test_reauth_success(hass: HomeAssistant) -> None: """Test we can reauth.""" entry = MockConfigEntry( diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index 7a8b02c93a0c6c..635aec520b5288 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -6,6 +6,7 @@ import aiohttp from pushover_complete import BadAPIRequestError import pytest +from requests_mock import Mocker from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.pushover.const import DOMAIN @@ -19,7 +20,7 @@ from tests.components.repairs import get_repairs -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=False) def mock_pushover(): """Mock pushover.""" with patch( @@ -33,6 +34,7 @@ async def test_setup( hass_ws_client: Callable[ [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] ], + mock_pushover: MagicMock, ) -> None: """Test integration failed due to an error.""" assert await async_setup_component( @@ -50,13 +52,15 @@ async def test_setup( }, ) await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN) + assert not hass.config_entries.async_entries(DOMAIN) issues = await get_repairs(hass, hass_ws_client) assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" + assert issues[0]["issue_id"] == "removed_yaml" -async def test_async_setup_entry_success(hass: HomeAssistant) -> None: +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_pushover: MagicMock +) -> None: """Test pushover successful setup.""" entry = MockConfigEntry( domain=DOMAIN, @@ -68,7 +72,7 @@ async def test_async_setup_entry_success(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED -async def test_unique_id_updated(hass: HomeAssistant) -> None: +async def test_unique_id_updated(hass: HomeAssistant, mock_pushover: MagicMock) -> None: """Test updating unique_id to new format.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, unique_id="MYUSERKEY") entry.add_to_hass(hass) @@ -106,3 +110,20 @@ async def test_async_setup_entry_failed_conn_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_entry_failed_json_error( + hass: HomeAssistant, requests_mock: Mocker +) -> None: + """Test pushover failed setup due to bad json response from library.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + requests_mock.post( + "https://api.pushover.net/1/users/validate.json", status_code=204 + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/radarr/fixtures/movie.json b/tests/components/radarr/fixtures/movie.json index 0f974859631849..b33ff6fc481984 100644 --- a/tests/components/radarr/fixtures/movie.json +++ b/tests/components/radarr/fixtures/movie.json @@ -21,8 +21,8 @@ "sortTitle": "string", "sizeOnDisk": 0, "overview": "string", - "inCinemas": "string", - "physicalRelease": "string", + "inCinemas": "2020-11-06T00:00:00Z", + "physicalRelease": "2019-03-19T00:00:00Z", "images": [ { "coverType": "poster", @@ -50,7 +50,7 @@ "certification": "string", "genres": ["string"], "tags": [0], - "added": "string", + "added": "2018-12-28T05:56:49Z", "ratings": { "votes": 0, "value": 0 diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index af00a1013e02e7..685f307d197488 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -76,19 +76,19 @@ def controller_mac_fixture(): return "aa:bb:cc:dd:ee:ff" -@pytest.fixture(name="data_api_versions", scope="session") +@pytest.fixture(name="data_api_versions", scope="package") def data_api_versions_fixture(): """Define API version data.""" return json.loads(load_fixture("api_versions_data.json", "rainmachine")) -@pytest.fixture(name="data_diagnostics_current", scope="session") +@pytest.fixture(name="data_diagnostics_current", scope="package") def data_diagnostics_current_fixture(): """Define current diagnostics data.""" return json.loads(load_fixture("diagnostics_current_data.json", "rainmachine")) -@pytest.fixture(name="data_machine_firmare_update_status", scope="session") +@pytest.fixture(name="data_machine_firmare_update_status", scope="package") def data_machine_firmare_update_status_fixture(): """Define machine firmware update status data.""" return json.loads( @@ -96,31 +96,31 @@ def data_machine_firmare_update_status_fixture(): ) -@pytest.fixture(name="data_programs", scope="session") +@pytest.fixture(name="data_programs", scope="package") def data_programs_fixture(): """Define program data.""" return json.loads(load_fixture("programs_data.json", "rainmachine")) -@pytest.fixture(name="data_provision_settings", scope="session") +@pytest.fixture(name="data_provision_settings", scope="package") def data_provision_settings_fixture(): """Define provisioning settings data.""" return json.loads(load_fixture("provision_settings_data.json", "rainmachine")) -@pytest.fixture(name="data_restrictions_current", scope="session") +@pytest.fixture(name="data_restrictions_current", scope="package") def data_restrictions_current_fixture(): """Define current restrictions settings data.""" return json.loads(load_fixture("restrictions_current_data.json", "rainmachine")) -@pytest.fixture(name="data_restrictions_universal", scope="session") +@pytest.fixture(name="data_restrictions_universal", scope="package") def data_restrictions_universal_fixture(): """Define universal restrictions settings data.""" return json.loads(load_fixture("restrictions_universal_data.json", "rainmachine")) -@pytest.fixture(name="data_zones", scope="session") +@pytest.fixture(name="data_zones", scope="package") def data_zones_fixture(): """Define zone data.""" return json.loads(load_fixture("zones_data.json", "rainmachine")) diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 7cf8406d2ae132..a3c03c956a4add 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -10,6 +10,9 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_rainmach """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "rainmachine", "title": "Mock Title", "data": { "ip_address": "192.168.1.100", @@ -18,14 +21,15 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_rainmach "ssl": True, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "coordinator": { - "api.versions": { - "apiVer": "4.6.1", - "hwVer": "3", - "swVer": "4.0.1144", - }, + "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, "machine.firmware_update_status": { "lastUpdateCheckTimestamp": 1657825288, "packageDetails": [], @@ -628,6 +632,9 @@ async def test_entry_diagnostics_failed_controller_diagnostics( controller.diagnostics.current.side_effect = RainMachineError assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "rainmachine", "title": "Mock Title", "data": { "ip_address": "192.168.1.100", @@ -636,14 +643,15 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "ssl": True, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "coordinator": { - "api.versions": { - "apiVer": "4.6.1", - "hwVer": "3", - "swVer": "4.0.1144", - }, + "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, "machine.firmware_update_status": { "lastUpdateCheckTimestamp": 1657825288, "packageDetails": [], diff --git a/tests/components/recollect_waste/conftest.py b/tests/components/recollect_waste/conftest.py index a0d002e9d9a435..9373a9aa9694a9 100644 --- a/tests/components/recollect_waste/conftest.py +++ b/tests/components/recollect_waste/conftest.py @@ -16,9 +16,13 @@ @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{config[CONF_PLACE_ID]}, {config[CONF_SERVICE_ID]}", + data=config, + ) entry.add_to_hass(hass) return entry @@ -51,9 +55,3 @@ async def setup_recollect_waste_fixture(hass, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "12345, 12345" diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index c9c9ba5a93f5b7..93978135681e97 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -1,4 +1,6 @@ """Test ReCollect Waste diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,7 +9,19 @@ async def test_entry_diagnostics( ): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": config_entry.as_dict(), + "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "recollect_waste", + "title": REDACTED, + "data": {"place_id": REDACTED, "service_id": "12345"}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, "data": [ { "date": { @@ -17,7 +31,7 @@ async def test_entry_diagnostics( "pickup_types": [ {"name": "garbage", "friendly_name": "Trash Collection"} ], - "area_name": "The Sun", + "area_name": REDACTED, } ], } diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 8d1929c7362a9f..0ddc76e442387f 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -3,7 +3,7 @@ import asyncio from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime import time from typing import Any, cast @@ -21,8 +21,6 @@ from . import db_schema_0 -from tests.common import async_fire_time_changed, fire_time_changed - DEFAULT_PURGE_TASKS = 3 @@ -69,9 +67,7 @@ def wait_recording_done(hass: HomeAssistant) -> None: def trigger_db_commit(hass: HomeAssistant) -> None: """Force the recorder to commit.""" - for _ in range(recorder.DEFAULT_COMMIT_INTERVAL): - # We only commit on time change - fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + recorder.get_instance(hass)._async_commit(dt_util.utcnow()) async def async_wait_recording_done(hass: HomeAssistant) -> None: @@ -100,8 +96,7 @@ async def async_wait_purge_done(hass: HomeAssistant, max: int = None) -> None: @ha.callback def async_trigger_db_commit(hass: HomeAssistant) -> None: """Force the recorder to commit. Async friendly.""" - for _ in range(recorder.DEFAULT_COMMIT_INTERVAL): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + recorder.get_instance(hass)._async_commit(dt_util.utcnow()) async def async_recorder_block_till_done(hass: HomeAssistant) -> None: diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index e829c2aa13b1bf..511520faa711cf 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError -async def test_async_pre_backup(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_pre_backup(recorder_mock, hass: HomeAssistant) -> None: """Test pre backup.""" with patch( "homeassistant.components.recorder.core.Recorder.lock_database" @@ -20,7 +20,7 @@ async def test_async_pre_backup(hass: HomeAssistant, recorder_mock) -> None: async def test_async_pre_backup_with_timeout( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test pre backup with timeout.""" with patch( @@ -32,7 +32,7 @@ async def test_async_pre_backup_with_timeout( async def test_async_pre_backup_with_migration( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test pre backup with migration.""" with patch( @@ -42,7 +42,7 @@ async def test_async_pre_backup_with_migration( await async_pre_backup(hass) -async def test_async_post_backup(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_post_backup(recorder_mock, hass: HomeAssistant) -> None: """Test post backup.""" with patch( "homeassistant.components.recorder.core.Recorder.unlock_database" @@ -51,7 +51,7 @@ async def test_async_post_backup(hass: HomeAssistant, recorder_mock) -> None: assert unlock_mock.called -async def test_async_post_backup_failure(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_post_backup_failure(recorder_mock, hass: HomeAssistant) -> None: """Test post backup failure.""" with patch( "homeassistant.components.recorder.core.Recorder.unlock_database", diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 62bb1b3fa8d72b..89a271dac02e26 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -71,7 +71,7 @@ def _get_events_with_session(): return filtered_states_entity_ids, filtered_events_entity_ids -async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock): +async def test_included_and_excluded_simple_case_no_domains(recorder_mock, hass): """Test filters with included and excluded without domains.""" filter_accept = {"sensor.kitchen4", "switch.kitchen"} filter_reject = { @@ -127,7 +127,7 @@ async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock) assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): +async def test_included_and_excluded_simple_case_no_globs(recorder_mock, hass): """Test filters with included and excluded without globs.""" filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} filter_reject = {"sensor.bli"} @@ -168,7 +168,7 @@ async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): async def test_included_and_excluded_simple_case_without_underscores( - hass, recorder_mock + recorder_mock, hass ): """Test filters with included and excluded without underscores.""" filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} @@ -221,7 +221,7 @@ async def test_included_and_excluded_simple_case_without_underscores( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_simple_case_with_underscores(hass, recorder_mock): +async def test_included_and_excluded_simple_case_with_underscores(recorder_mock, hass): """Test filters with included and excluded with underscores.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} @@ -273,7 +273,7 @@ async def test_included_and_excluded_simple_case_with_underscores(hass, recorder assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_complex_case(hass, recorder_mock): +async def test_included_and_excluded_complex_case(recorder_mock, hass): """Test filters with included and excluded with a complex filter.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} filter_reject = { @@ -330,7 +330,7 @@ async def test_included_and_excluded_complex_case(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_entities_and_excluded_domain(hass, recorder_mock): +async def test_included_entities_and_excluded_domain(recorder_mock, hass): """Test filters with included entities and excluded domain.""" filter_accept = { "media_player.test", @@ -376,7 +376,7 @@ async def test_included_entities_and_excluded_domain(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_domain_included_excluded(hass, recorder_mock): +async def test_same_domain_included_excluded(recorder_mock, hass): """Test filters with the same domain included and excluded.""" filter_accept = { "media_player.test", @@ -422,7 +422,7 @@ async def test_same_domain_included_excluded(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_entity_included_excluded(hass, recorder_mock): +async def test_same_entity_included_excluded(recorder_mock, hass): """Test filters with the same entity included and excluded.""" filter_accept = { "media_player.test", @@ -468,7 +468,7 @@ async def test_same_entity_included_excluded(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_mock): +async def test_same_entity_included_excluded_include_domain_wins(recorder_mock, hass): """Test filters with domain and entities and the include domain wins.""" filter_accept = { "media_player.test2", @@ -516,7 +516,7 @@ async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_ assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_specificly_included_entity_always_wins(hass, recorder_mock): +async def test_specificly_included_entity_always_wins(recorder_mock, hass): """Test specificlly included entity always wins.""" filter_accept = { "media_player.test2", @@ -564,7 +564,7 @@ async def test_specificly_included_entity_always_wins(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_specificly_included_entity_always_wins_over_glob(hass, recorder_mock): +async def test_specificly_included_entity_always_wins_over_glob(recorder_mock, hass): """Test specificlly included entity always wins over a glob.""" filter_accept = { "sensor.apc900va_status", diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index f18ba0768ca705..6362b83f78a328 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -254,6 +254,9 @@ def set_state(state): start = dt_util.utcnow() point = start + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=2) + point3 = start + timedelta(seconds=1, microseconds=3) + point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) with patch( @@ -265,12 +268,19 @@ def set_state(state): with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point ): - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] + states = [set_state("idle")] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("Netflix")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 + ): + states.append(set_state("Plex")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 + ): + states.append(set_state("YouTube")) with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end @@ -650,10 +660,15 @@ def set_state(entity_id, state, **kwargs): async def test_state_changes_during_period_query_during_migration_to_schema_25( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -700,10 +715,15 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -746,10 +766,15 @@ async def test_get_states_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -795,8 +820,8 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( async def test_get_full_significant_states_handles_empty_last_changed( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, ): """Test getting states when last_changed is null.""" await async_setup_recorder_instance(hass, {}) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 05ae1f1a372c7c..ca4cbc9a4f94f2 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -17,12 +17,15 @@ CONF_AUTO_PURGE, CONF_AUTO_REPACK, CONF_COMMIT_INTERVAL, + CONF_DB_MAX_RETRIES, + CONF_DB_RETRY_WAIT, CONF_DB_URL, CONFIG_SCHEMA, DOMAIN, SQLITE_URL_PREFIX, Recorder, get_instance, + pool, ) from homeassistant.components.recorder.const import KEEPALIVE_TIME from homeassistant.components.recorder.db_schema import ( @@ -89,7 +92,7 @@ def _default_recorder(hass): async def test_shutdown_before_startup_finishes( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, tmp_path + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant, tmp_path ): """Test shutdown before recorder starts is clean.""" @@ -121,8 +124,8 @@ async def test_shutdown_before_startup_finishes( async def test_canceled_before_startup_finishes( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ): """Test recorder shuts down when its startup future is canceled out from under it.""" @@ -142,7 +145,7 @@ async def test_canceled_before_startup_finishes( ) -async def test_shutdown_closes_connections(hass, recorder_mock): +async def test_shutdown_closes_connections(recorder_mock, hass): """Test shutdown closes connections.""" hass.state = CoreState.not_running @@ -168,7 +171,7 @@ def _ensure_connected(): async def test_state_gets_saved_when_set_before_start_event( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test we can record an event when starting with not running.""" @@ -194,7 +197,7 @@ async def test_state_gets_saved_when_set_before_start_event( assert db_states[0].event_id is None -async def test_saving_state(hass: HomeAssistant, recorder_mock): +async def test_saving_state(recorder_mock, hass: HomeAssistant): """Test saving and restoring a state.""" entity_id = "test.recorder" state = "restoring_from_db" @@ -217,7 +220,7 @@ async def test_saving_state(hass: HomeAssistant, recorder_mock): async def test_saving_many_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test we expire after many commits.""" instance = await async_setup_recorder_instance( @@ -245,7 +248,7 @@ async def test_saving_many_states( async def test_saving_state_with_intermixed_time_changes( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ): """Test saving states with intermixed time changes.""" entity_id = "test.recorder" @@ -345,7 +348,7 @@ def _throw_if_state_in_session(*args, **kwargs): async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( - hass, async_setup_recorder_instance, caplog + async_setup_recorder_instance, hass, caplog ): """Test forcing shutdown.""" instance = await async_setup_recorder_instance(hass) @@ -662,6 +665,23 @@ def test_recorder_setup_failure(hass): hass.stop() +def test_recorder_validate_schema_failure(hass): + """Test some exceptions.""" + recorder_helper.async_initialize_recorder(hass) + with patch( + "homeassistant.components.recorder.migration._get_schema_version" + ) as inspect_schema_version, patch( + "homeassistant.components.recorder.core.time.sleep" + ): + inspect_schema_version.side_effect = ImportError("driver not found") + rec = _default_recorder(hass) + rec.async_initialize() + rec.start() + rec.join() + + hass.stop() + + def test_recorder_setup_failure_without_event_listener(hass): """Test recorder setup failure when the event listener is not setup.""" recorder_helper.async_initialize_recorder(hass) @@ -982,54 +1002,48 @@ def test_statistics_runs_initiated(hass_recorder): ) - timedelta(minutes=5) -def test_compile_missing_statistics(tmpdir): +@pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") +def test_compile_missing_statistics(tmpdir, freezer): """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=now - ): - - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 1 - last_run = process_timestamp(statistics_runs[0].start) - assert last_run == now - timedelta(minutes=5) + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert last_run == now - timedelta(minutes=5) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=now + timedelta(hours=1), - ): + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) + # Start Home Assistant one hour later + freezer.tick(timedelta(hours=1)) + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) - with session_scope(hass=hass) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 13 # 12 5-minute runs - last_run = process_timestamp(statistics_runs[1].start) - assert last_run == now + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 13 # 12 5-minute runs + last_run = process_timestamp(statistics_runs[1].start) + assert last_run == now - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() def test_saving_sets_old_state(hass_recorder): @@ -1356,8 +1370,8 @@ def test_entity_id_filter(hass_recorder): async def test_database_lock_and_unlock( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test writing events during lock getting written after unlocking.""" @@ -1398,8 +1412,8 @@ def _get_db_events(): async def test_database_lock_and_overflow( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test writing events during lock leading to overflow the queue causes the database to unlock.""" @@ -1436,8 +1450,12 @@ def _get_db_events(): assert not instance.unlock_database() -async def test_database_lock_timeout(hass, recorder_mock): +async def test_database_lock_timeout(recorder_mock, hass, recorder_db_url): """Test locking database timeout when recorder stopped.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) instance = get_instance(hass) @@ -1459,7 +1477,7 @@ def run(self, instance: Recorder) -> None: block_task.event.set() -async def test_database_lock_without_instance(hass, recorder_mock): +async def test_database_lock_without_instance(recorder_mock, hass): """Test database lock doesn't fail if instance is not initialized.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -1480,8 +1498,8 @@ async def test_in_memory_database(hass, caplog): async def test_database_connection_keep_alive( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ): """Test we keep alive socket based dialects.""" @@ -1500,11 +1518,16 @@ async def test_database_connection_keep_alive( async def test_database_connection_keep_alive_disabled_on_sqlite( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + recorder_db_url: str, ): """Test we do not do keep alive for sqlite.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite, keepalive runs on other engines + return + instance = await async_setup_recorder_instance(hass) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await instance.async_recorder_ready.wait() @@ -1576,7 +1599,7 @@ def test_deduplication_state_attributes_inside_commit_interval(hass_recorder, ca assert first_attributes_id == last_attributes_id -async def test_async_block_till_done(hass, async_setup_recorder_instance): +async def test_async_block_till_done(async_setup_recorder_instance, hass): """Test we can block until recordering is done.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) @@ -1596,3 +1619,162 @@ def _fetch_states(): states = await instance.async_add_executor_job(_fetch_states) assert len(states) == 2 await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "db_url, echo", + ( + ("sqlite://blabla", None), + ("mariadb://blabla", False), + ("mysql://blabla", False), + ("mariadb+pymysql://blabla", False), + ("mysql+pymysql://blabla", False), + ("postgresql://blabla", False), + ), +) +async def test_disable_echo(hass, db_url, echo, caplog): + """Test echo is disabled for non sqlite databases.""" + recorder_helper.async_initialize_recorder(hass) + + class MockEvent: + def listen(self, _, _2, callback): + callback(None, None) + + mock_event = MockEvent() + with patch( + "homeassistant.components.recorder.core.create_engine" + ) as create_engine_mock, patch( + "homeassistant.components.recorder.core.sqlalchemy_event", mock_event + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: db_url}}) + create_engine_mock.assert_called_once() + assert create_engine_mock.mock_calls[0][2].get("echo") == echo + + +@pytest.mark.parametrize( + "config_url, expected_connect_args", + ( + ( + "mariadb://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mariadb+pymysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql+pymysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME?charset=utf8mb4", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME?blah=bleh&charset=other", + {"charset": "utf8mb4"}, + ), + ( + "postgresql://blabla", + {}, + ), + ( + "sqlite://blabla", + {}, + ), + ), +) +async def test_mysql_missing_utf8mb4(hass, config_url, expected_connect_args): + """Test recorder fails to setup if charset=utf8mb4 is missing from db_url.""" + recorder_helper.async_initialize_recorder(hass) + + class MockEvent: + def listen(self, _, _2, callback): + callback(None, None) + + mock_event = MockEvent() + with patch( + "homeassistant.components.recorder.core.create_engine" + ) as create_engine_mock, patch( + "homeassistant.components.recorder.core.sqlalchemy_event", mock_event + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: config_url}}) + create_engine_mock.assert_called_once() + + connect_args = create_engine_mock.mock_calls[0][2].get("connect_args", {}) + for key, value in expected_connect_args.items(): + assert connect_args[key] == value + + +@pytest.mark.parametrize( + "config_url", + ( + "mysql://user:password@SERVER_IP/DB_NAME", + "mysql://user:password@SERVER_IP/DB_NAME?charset=utf8mb4", + "mysql://user:password@SERVER_IP/DB_NAME?blah=bleh&charset=other", + ), +) +async def test_connect_args_priority(hass, config_url): + """Test connect_args has priority over URL query.""" + connect_params = [] + recorder_helper.async_initialize_recorder(hass) + + class MockDialect: + """Non functioning dialect, good enough that SQLAlchemy tries connecting.""" + + __bases__ = [] + _has_events = False + + def __init__(*args, **kwargs): + ... + + def connect(self, *args, **params): + nonlocal connect_params + connect_params.append(params) + return True + + def create_connect_args(self, url): + return ([], {"charset": "invalid"}) + + @classmethod + def dbapi(cls): + ... + + def engine_created(*args): + ... + + def get_dialect_pool_class(self, *args): + return pool.RecorderPool + + def initialize(*args): + ... + + def on_connect_url(self, url): + return False + + class MockEntrypoint: + def engine_created(*_): + ... + + def get_dialect_cls(*_): + return MockDialect + + with patch("sqlalchemy.engine.url.URL._get_entrypoint", MockEntrypoint), patch( + "sqlalchemy.engine.create.util.get_cls_kwargs", return_value=["echo"] + ): + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DB_URL: config_url, + CONF_DB_MAX_RETRIES: 1, + CONF_DB_RETRY_WAIT: 0, + } + }, + ) + assert connect_params[0]["charset"] == "utf8mb4" diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 9e0609de5b6272..45268ae819b061 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -134,14 +134,16 @@ async def test_database_migration_encounters_corruption(hass): sqlite3_exception.__cause__ = sqlite3.DatabaseError() with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.schema_is_current", - side_effect=[False, True], + "homeassistant.components.recorder.migration._schema_is_current", + side_effect=[False], ), patch( "homeassistant.components.recorder.migration.migrate_schema", side_effect=sqlite3_exception, ), patch( "homeassistant.components.recorder.core.move_away_broken_database" - ) as move_away: + ) as move_away, patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + ): recorder_helper.async_initialize_recorder(hass) await async_setup_component( hass, "recorder", {"recorder": {"db_url": "sqlite://"}} @@ -159,8 +161,8 @@ async def test_database_migration_encounters_corruption_not_sqlite(hass): assert recorder.util.async_migration_in_progress(hass) is False with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.schema_is_current", - side_effect=[False, True], + "homeassistant.components.recorder.migration._schema_is_current", + side_effect=[False], ), patch( "homeassistant.components.recorder.migration.migrate_schema", side_effect=DatabaseError("statement", {}, []), diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c6c447c01c9422..f135ae8af435ae 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -53,7 +53,7 @@ def mock_use_sqlite(request): async def test_purge_old_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old states.""" instance = await async_setup_recorder_instance(hass) @@ -135,9 +135,16 @@ async def test_purge_old_states( async def test_purge_old_states_encouters_database_corruption( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, + recorder_db_url: str, ): """Test database image image is malformed while deleting old states.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite, wiping the database on error only happens + # with SQLite. + return + await async_setup_recorder_instance(hass) await _add_test_states(hass) @@ -165,8 +172,8 @@ async def test_purge_old_states_encouters_database_corruption( async def test_purge_old_states_encounters_temporary_mysql_error( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog, ): """Test retry on specific mysql operational errors.""" @@ -196,8 +203,8 @@ async def test_purge_old_states_encounters_temporary_mysql_error( async def test_purge_old_states_encounters_operational_error( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog, ): """Test error on operational errors that are not mysql does not retry.""" @@ -222,7 +229,7 @@ async def test_purge_old_states_encounters_operational_error( async def test_purge_old_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) @@ -259,7 +266,7 @@ async def test_purge_old_events( async def test_purge_old_recorder_runs( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old recorder runs keeps current run.""" instance = await async_setup_recorder_instance(hass) @@ -295,7 +302,7 @@ async def test_purge_old_recorder_runs( async def test_purge_old_statistics_runs( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old statistics runs keeps the latest run.""" instance = await async_setup_recorder_instance(hass) @@ -320,8 +327,8 @@ async def test_purge_old_statistics_runs( @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_method( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, use_sqlite: bool, ): @@ -364,7 +371,7 @@ def assert_statistic_runs_equal(run1, run2): assert recorder_runs.count() == 7 runs_before_purge = recorder_runs.all() - statistics_runs = session.query(StatisticsRuns) + statistics_runs = session.query(StatisticsRuns).order_by(StatisticsRuns.run_id) assert statistics_runs.count() == 7 statistic_runs_before_purge = statistics_runs.all() @@ -431,13 +438,16 @@ def assert_statistic_runs_equal(run1, run2): await hass.services.async_call("recorder", "purge", service_data=service_data) await hass.async_block_till_done() await async_wait_purge_done(hass) - assert "Vacuuming SQL DB to free space" in caplog.text + assert ( + "Vacuuming SQL DB to free space" in caplog.text + or "Optimizing SQL DB to free space" in caplog.text + ) @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_edge_case( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test states and events are purged even if they occurred shortly before purge_before.""" @@ -503,8 +513,8 @@ async def _add_db_entries(hass: HomeAssistant, timestamp: datetime) -> None: async def test_purge_cutoff_date( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test states and events are purged only if they occurred before "now() - keep_days".""" @@ -651,8 +661,8 @@ async def _add_db_entries(hass: HomeAssistant, cutoff: datetime, rows: int) -> N @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered states are purged.""" @@ -837,8 +847,8 @@ def _add_db_entries(hass: HomeAssistant) -> None: @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states_to_empty( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered states are purged all the way to an empty db.""" @@ -890,8 +900,8 @@ def _add_db_entries(hass: HomeAssistant) -> None: @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_without_state_attributes_filtered_states_to_empty( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered legacy states without state attributes are purged all the way to an empty db.""" @@ -964,8 +974,8 @@ def _add_db_entries(hass: HomeAssistant) -> None: async def test_purge_filtered_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test filtered events are purged.""" config: ConfigType = {"exclude": {"event_types": ["EVENT_PURGE"]}} @@ -1052,8 +1062,8 @@ def _add_db_entries(hass: HomeAssistant) -> None: async def test_purge_filtered_events_state_changed( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test filtered state_changed events are purged. This should also remove all states.""" config: ConfigType = {"exclude": {"event_types": [EVENT_STATE_CHANGED]}} @@ -1155,7 +1165,7 @@ def _add_db_entries(hass: HomeAssistant) -> None: async def test_purge_entities( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test purging of specific entities.""" await async_setup_recorder_instance(hass) @@ -1527,7 +1537,7 @@ def _add_state_and_state_changed_event( async def test_purge_many_old_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) @@ -1580,7 +1590,7 @@ async def test_purge_many_old_events( async def test_purge_can_mix_legacy_and_new_format( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test purging with legacy a new events.""" instance = await async_setup_recorder_instance(hass) diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index ff4a5e5d701998..7504404f779715 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -8,7 +8,7 @@ from homeassistant.util import dt as dt_util -async def test_run_history(hass, recorder_mock): +async def test_run_history(recorder_mock, hass): """Test the run history gives the correct run.""" instance = recorder.get_instance(hass) now = dt_util.utcnow() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6d96b97b89ca79..aae6fcf91cbfad 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -448,9 +448,9 @@ def test_statistics_duplicated(hass_recorder, caplog): ), ) async def test_import_statistics( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, source, statistic_id, @@ -885,10 +885,148 @@ def test_import_statistics_errors(hass_recorder, caplog): assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_weekly_statistics(hass_recorder, caplog, timezone): + """Test weekly statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-09 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="week") + week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": week1_start.isoformat(), + "end": week1_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "statistic_id": "test:total_energy_import", + "start": week2_start.isoformat(), + "end": week2_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + ] + } + + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["not", "the", "same", "test:total_energy_import"], + period="week", + ) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": week1_start.isoformat(), + "end": week1_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "statistic_id": "test:total_energy_import", + "start": week2_start.isoformat(), + "end": week2_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + ] + } + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="month" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") def test_monthly_statistics(hass_recorder, caplog, timezone): - """Test inserting external statistics.""" + """Test monthly statistics.""" dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) hass = hass_recorder() diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index b465ee89ebeb78..0bb440a2dc89a8 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -14,8 +14,12 @@ from tests.common import SetupRecorderInstanceT, get_system_health_info -async def test_recorder_system_health(hass, recorder_mock): +async def test_recorder_system_health(recorder_mock, hass, recorder_db_url): """Test recorder system health.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + assert await async_setup_component(hass, "system_health", {}) await async_wait_recording_done(hass) info = await get_system_health_info(hass, "recorder") @@ -32,7 +36,7 @@ async def test_recorder_system_health(hass, recorder_mock): @pytest.mark.parametrize( "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) -async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialect_name): +async def test_recorder_system_health_alternate_dbms(recorder_mock, hass, dialect_name): """Test recorder system health.""" assert await async_setup_component(hass, "system_health", {}) await async_wait_recording_done(hass) @@ -57,7 +61,7 @@ async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialec "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) async def test_recorder_system_health_db_url_missing_host( - hass, recorder_mock, dialect_name + recorder_mock, hass, dialect_name ): """Test recorder system health with a db_url without a hostname.""" assert await async_setup_component(hass, "system_health", {}) @@ -85,9 +89,15 @@ async def test_recorder_system_health_db_url_missing_host( async def test_recorder_system_health_crashed_recorder_runs_table( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, + recorder_db_url: str, ): """Test recorder system health with crashed recorder runs table.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + with patch("homeassistant.components.recorder.run_history.RunHistory.load_from_db"): assert await async_setup_component(hass, "system_health", {}) instance = await async_setup_recorder_instance(hass) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 70f4eb0da70a61..9000379c17df76 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -40,8 +40,14 @@ def test_session_scope_not_setup(hass_recorder): pass -def test_recorder_bad_commit(hass_recorder): +def test_recorder_bad_commit(hass_recorder, recorder_db_url): """Bad _commit should retry 3 times.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: mysql does not raise an OperationalError + # which triggers retries for the bad query below, it raises ProgrammingError + # on which we give up + return + hass = hass_recorder() def work(session): @@ -542,8 +548,12 @@ def test_warn_unsupported_dialect(caplog, dialect, message): assert message in caplog.text -def test_basic_sanity_check(hass_recorder): +def test_basic_sanity_check(hass_recorder, recorder_db_url): """Test the basic sanity checks with a missing table.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() cursor = util.get_instance(hass).engine.raw_connection().cursor() @@ -556,8 +566,12 @@ def test_basic_sanity_check(hass_recorder): util.basic_sanity_check(cursor) -def test_combined_checks(hass_recorder, caplog): +def test_combined_checks(hass_recorder, caplog, recorder_db_url): """Run Checks on the open database.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() instance = util.get_instance(hass) instance.db_retry_wait = 0 @@ -635,8 +649,12 @@ def test_end_incomplete_runs(hass_recorder, caplog): assert "Ended unfinished session" in caplog.text -def test_periodic_db_cleanups(hass_recorder): +def test_periodic_db_cleanups(hass_recorder, recorder_db_url): """Test periodic db cleanups.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() with patch.object(util.get_instance(hass).engine, "connect") as connect_mock: util.periodic_db_cleanups(util.get_instance(hass)) @@ -651,8 +669,8 @@ def test_periodic_db_cleanups(hass_recorder): @patch("homeassistant.components.recorder.pool.check_loop") async def test_write_lock_db( skip_check_loop, - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test database write lock.""" diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 6058fd6a2e537b..00e9d0d35b4e6b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,14 +1,17 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name +import datetime from datetime import timedelta +from statistics import fmean import threading -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun import freeze_time import pytest from pytest import approx from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -19,7 +22,7 @@ from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( async_recorder_block_till_done, @@ -122,11 +125,11 @@ } -async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): +async def test_statistics_during_period(recorder_mock, hass, hass_ws_client): """Test statistics_during_period.""" now = dt_util.utcnow() - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.test", 10, attributes=POWER_SENSOR_KW_ATTRIBUTES) @@ -178,6 +181,448 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): } +@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +async def test_statistic_during_period(recorder_mock, hass, hass_ws_client): + """Test statistic_during_period.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-3) + + imported_stats_5min = [ + { + "start": (start + timedelta(minutes=5 * i)), + "max": i * 2, + "mean": i, + "min": -76 + i * 2, + "sum": i, + } + for i in range(0, 39) + ] + imported_stats = [ + { + "start": imported_stats_5min[i * 12]["start"], + "max": max( + stat["max"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "mean": fmean( + stat["mean"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "min": min( + stat["min"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "sum": imported_stats_5min[i * 12 + 11]["sum"], + } + for i in range(0, 3) + ] + imported_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": "kWh", + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + # No data for this period yet + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": now.isoformat(), + "end_time": now.isoformat(), + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": None, + "mean": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should also include imported_statistics_5min[:] + start_time = "2022-10-21T04:00:00+00:00" + end_time = "2022-10-21T07:15:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should also include imported_statistics_5min[:] + start_time = "2022-10-20T04:00:00+00:00" + end_time = "2022-10-21T08:20:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should include imported_statistics_5min[26:] + start_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == start_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:]), + "min": min(stat["min"] for stat in imported_stats_5min[26:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should also include imported_statistics_5min[26:] + start_time = "2022-10-21T06:09:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:]), + "min": min(stat["min"] for stat in imported_stats_5min[26:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should include imported_statistics_5min[:26] + end_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == end_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:26]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:26]), + "min": min(stat["min"] for stat in imported_stats_5min[:26]), + "change": imported_stats_5min[25]["sum"] - 0, + } + + # This should include imported_statistics_5min[26:32] (less than a full hour) + start_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == start_time + end_time = "2022-10-21T06:40:00+00:00" + assert imported_stats_5min[32]["start"].isoformat() == end_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:32]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:32]), + "min": min(stat["min"] for stat in imported_stats_5min[26:32]), + "change": imported_stats_5min[31]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should include imported_statistics[2:] + imported_statistics_5min[36:] + start_time = "2022-10-21T06:00:00+00:00" + assert imported_stats_5min[24]["start"].isoformat() == start_time + assert imported_stats[2]["start"].isoformat() == start_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:]), + "min": min(stat["min"] for stat in imported_stats_5min[24:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[23]["sum"], + } + + # This should also include imported_statistics[2:] + imported_statistics_5min[36:] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1, "minutes": 25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:]), + "min": min(stat["min"] for stat in imported_stats_5min[24:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[23]["sum"], + } + + # This should include imported_statistics[2:3] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1}, + "offset": {"minutes": -25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:36]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:36]), + "min": min(stat["min"] for stat in imported_stats_5min[24:36]), + "change": imported_stats_5min[35]["sum"] - imported_stats_5min[23]["sum"], + } + + # Test we can get only selected types + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "types": ["max", "change"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # Test we can convert units + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "units": {"energy": "MWh"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]) / 1000, + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]) / 1000, + "min": min(stat["min"] for stat in imported_stats_5min[:]) / 1000, + "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) + / 1000, + } + + # Test we can automatically convert units + hass.states.async_set("sensor.test", None, attributes=ENERGY_SENSOR_WH_ATTRIBUTES) + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]) * 1000, + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]) * 1000, + "min": min(stat["min"] for stat in imported_stats_5min[:]) * 1000, + "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) + * 1000, + } + + +@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.parametrize( + "calendar_period, start_time, end_time", + ( + ( + {"period": "hour"}, + "2022-10-21T07:00:00+00:00", + "2022-10-21T08:00:00+00:00", + ), + ( + {"period": "hour", "offset": -1}, + "2022-10-21T06:00:00+00:00", + "2022-10-21T07:00:00+00:00", + ), + ( + {"period": "day"}, + "2022-10-21T07:00:00+00:00", + "2022-10-22T07:00:00+00:00", + ), + ( + {"period": "day", "offset": -1}, + "2022-10-20T07:00:00+00:00", + "2022-10-21T07:00:00+00:00", + ), + ( + {"period": "week"}, + "2022-10-17T07:00:00+00:00", + "2022-10-24T07:00:00+00:00", + ), + ( + {"period": "week", "offset": -1}, + "2022-10-10T07:00:00+00:00", + "2022-10-17T07:00:00+00:00", + ), + ( + {"period": "month"}, + "2022-10-01T07:00:00+00:00", + "2022-11-01T07:00:00+00:00", + ), + ( + {"period": "month", "offset": -1}, + "2022-09-01T07:00:00+00:00", + "2022-10-01T07:00:00+00:00", + ), + ( + {"period": "year"}, + "2022-01-01T08:00:00+00:00", + "2023-01-01T08:00:00+00:00", + ), + ( + {"period": "year", "offset": -1}, + "2021-01-01T08:00:00+00:00", + "2022-01-01T08:00:00+00:00", + ), + ), +) +async def test_statistic_during_period_calendar( + recorder_mock, hass, hass_ws_client, calendar_period, start_time, end_time +): + """Test statistic_during_period.""" + client = await hass_ws_client() + + # Try requesting data for the current hour + with patch( + "homeassistant.components.recorder.websocket_api.statistic_during_period", + return_value={}, + ) as statistic_during_period: + await client.send_json( + { + "id": 1, + "type": "recorder/statistic_during_period", + "calendar": calendar_period, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + statistic_during_period.assert_called_once_with( + hass, ANY, ANY, "sensor.test", None, units=None + ) + assert statistic_during_period.call_args[0][1].isoformat() == start_time + assert statistic_during_period.call_args[0][2].isoformat() == end_time + assert response["success"] + + @pytest.mark.parametrize( "attributes, state, value, custom_units, converted_value", [ @@ -200,9 +645,9 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): ], ) async def test_statistics_during_period_unit_conversion( + recorder_mock, hass, hass_ws_client, - recorder_mock, attributes, state, value, @@ -293,9 +738,9 @@ async def test_statistics_during_period_unit_conversion( ], ) async def test_sum_statistics_during_period_unit_conversion( + recorder_mock, hass, hass_ws_client, - recorder_mock, attributes, state, value, @@ -386,7 +831,7 @@ async def test_sum_statistics_during_period_unit_conversion( ], ) async def test_statistics_during_period_invalid_unit_conversion( - hass, hass_ws_client, recorder_mock, custom_units + recorder_mock, hass, hass_ws_client, custom_units ): """Test statistics_during_period.""" now = dt_util.utcnow() @@ -425,13 +870,13 @@ async def test_statistics_during_period_invalid_unit_conversion( async def test_statistics_during_period_in_the_past( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period in the past.""" hass.config.set_time_zone("UTC") now = dt_util.utcnow().replace() - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -548,7 +993,7 @@ async def test_statistics_during_period_in_the_past( async def test_statistics_during_period_bad_start_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period.""" client = await hass_ws_client() @@ -566,7 +1011,7 @@ async def test_statistics_during_period_bad_start_time( async def test_statistics_during_period_bad_end_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period.""" now = dt_util.utcnow() @@ -589,34 +1034,64 @@ async def test_statistics_during_period_bad_end_time( @pytest.mark.parametrize( "units, attributes, display_unit, statistics_unit, unit_class", [ - (IMPERIAL_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), + (US_CUSTOMARY_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), (METRIC_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), - (IMPERIAL_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "ft", "distance"), + ( + US_CUSTOMARY_SYSTEM, + DISTANCE_SENSOR_FT_ATTRIBUTES, + "ft", + "ft", + "distance", + ), (METRIC_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "ft", "distance"), - (IMPERIAL_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), + (US_CUSTOMARY_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), (METRIC_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), - (IMPERIAL_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), + (US_CUSTOMARY_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), (METRIC_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), - (IMPERIAL_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), (METRIC_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), - (IMPERIAL_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "hPa", "pressure"), + ( + US_CUSTOMARY_SYSTEM, + PRESSURE_SENSOR_HPA_ATTRIBUTES, + "hPa", + "hPa", + "pressure", + ), (METRIC_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "hPa", "pressure"), - (IMPERIAL_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), + (US_CUSTOMARY_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), (METRIC_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_C_ATTRIBUTES, + "°C", + "°C", + "temperature", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°F", "temperature"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_F_ATTRIBUTES, + "°F", + "°F", + "temperature", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°F", "temperature"), - (IMPERIAL_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), + (US_CUSTOMARY_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), (METRIC_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), - (IMPERIAL_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, "ft³", "ft³", "volume"), + ( + US_CUSTOMARY_SYSTEM, + VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, + "ft³", + "ft³", + "volume", + ), (METRIC_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, "ft³", "ft³", "volume"), ], ) async def test_list_statistic_ids( + recorder_mock, hass, hass_ws_client, - recorder_mock, units, attributes, display_unit, @@ -724,7 +1199,7 @@ async def test_list_statistic_ids( assert response["result"] == [] -async def test_validate_statistics(hass, hass_ws_client, recorder_mock): +async def test_validate_statistics(recorder_mock, hass, hass_ws_client): """Test validate_statistics can be called.""" id = 1 @@ -746,7 +1221,7 @@ async def assert_validation_result(client, expected_result): await assert_validation_result(client, {}) -async def test_clear_statistics(hass, hass_ws_client, recorder_mock): +async def test_clear_statistics(recorder_mock, hass, hass_ws_client): """Test removing statistics.""" now = dt_util.utcnow() @@ -873,7 +1348,7 @@ async def test_clear_statistics(hass, hass_ws_client, recorder_mock): "new_unit, new_unit_class", [("dogs", None), (None, None), ("W", "power")] ) async def test_update_statistics_metadata( - hass, hass_ws_client, recorder_mock, new_unit, new_unit_class + recorder_mock, hass, hass_ws_client, new_unit, new_unit_class ): """Test removing statistics.""" now = dt_util.utcnow() @@ -964,7 +1439,7 @@ async def test_update_statistics_metadata( } -async def test_change_statistics_unit(hass, hass_ws_client, recorder_mock): +async def test_change_statistics_unit(recorder_mock, hass, hass_ws_client): """Test change unit of recorded statistics.""" now = dt_util.utcnow() @@ -1083,7 +1558,7 @@ async def test_change_statistics_unit(hass, hass_ws_client, recorder_mock): async def test_change_statistics_unit_errors( - hass, hass_ws_client, recorder_mock, caplog + recorder_mock, hass, hass_ws_client, caplog ): """Test change unit of recorded statistics.""" now = dt_util.utcnow() @@ -1200,7 +1675,7 @@ async def assert_statistics(expected): await assert_statistics(expected_statistics) -async def test_recorder_info(hass, hass_ws_client, recorder_mock): +async def test_recorder_info(recorder_mock, hass, hass_ws_client): """Test getting recorder status.""" client = await hass_ws_client() @@ -1329,9 +1804,13 @@ async def test_backup_start_no_recorder( async def test_backup_start_timeout( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url ): """Test getting backup start when recorder is not present.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + client = await hass_ws_client(hass, hass_supervisor_access_token) # Ensure there are no queued events @@ -1348,7 +1827,7 @@ async def test_backup_start_timeout( async def test_backup_end( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token ): """Test backup start.""" client = await hass_ws_client(hass, hass_supervisor_access_token) @@ -1366,9 +1845,13 @@ async def test_backup_end( async def test_backup_end_without_start( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url ): """Test backup start.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + client = await hass_ws_client(hass, hass_supervisor_access_token) # Ensure there are no queued events @@ -1400,7 +1883,7 @@ async def test_backup_end_without_start( ], ) async def test_get_statistics_metadata( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit_class + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit_class ): """Test get_statistics_metadata.""" now = dt_util.utcnow() @@ -1545,7 +2028,7 @@ async def test_get_statistics_metadata( ), ) async def test_import_statistics( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test importing statistics.""" client = await hass_ws_client() @@ -1557,20 +2040,20 @@ async def test_import_statistics( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -1583,8 +2066,8 @@ async def test_import_statistics( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -1674,7 +2157,7 @@ async def test_import_statistics( { "id": 2, "type": "recorder/import_statistics", - "metadata": external_metadata, + "metadata": imported_metadata, "stats": [external_statistics], } ) @@ -1726,7 +2209,7 @@ async def test_import_statistics( { "id": 3, "type": "recorder/import_statistics", - "metadata": external_metadata, + "metadata": imported_metadata, "stats": [external_statistics], } ) @@ -1772,7 +2255,7 @@ async def test_import_statistics( ), ) async def test_adjust_sum_statistics_energy( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test adjusting statistics.""" client = await hass_ws_client() @@ -1784,20 +2267,20 @@ async def test_adjust_sum_statistics_energy( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -1810,8 +2293,8 @@ async def test_adjust_sum_statistics_energy( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -1968,7 +2451,7 @@ async def test_adjust_sum_statistics_energy( ), ) async def test_adjust_sum_statistics_gas( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test adjusting statistics.""" client = await hass_ws_client() @@ -1980,20 +2463,20 @@ async def test_adjust_sum_statistics_gas( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -2006,8 +2489,8 @@ async def test_adjust_sum_statistics_gas( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -2168,9 +2651,9 @@ async def test_adjust_sum_statistics_gas( ), ) async def test_adjust_sum_statistics_errors( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, state_unit, statistic_unit, @@ -2191,20 +2674,20 @@ async def test_adjust_sum_statistics_errors( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -2217,8 +2700,8 @@ async def test_adjust_sum_statistics_errors( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 221ce5be8d2a1b..c4ff094638b0e5 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -47,9 +47,9 @@ def client_fixture(account): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -77,9 +77,3 @@ async def setup_ridwell_fixture(hass, client, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@email.com" diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index 8427fa13e11aed..96d1531ac84a7d 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,10 +1,25 @@ """Test Ridwell diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_ridwell): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "ridwell", + "title": REDACTED, + "data": {"username": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, "data": [ { "_async_request": None, @@ -31,5 +46,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_ridwell) "repr": "", }, } - ] + ], } diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py index 2325d88c03faa9..71cbd04f391458 100644 --- a/tests/components/risco/test_binary_sensor.py +++ b/tests/components/risco/test_binary_sensor.py @@ -13,6 +13,8 @@ FIRST_ENTITY_ID = "binary_sensor.zone_0" SECOND_ENTITY_ID = "binary_sensor.zone_1" +FIRST_ALARMED_ENTITY_ID = FIRST_ENTITY_ID + "_alarmed" +SECOND_ALARMED_ENTITY_ID = SECOND_ENTITY_ID + "_alarmed" @pytest.fixture @@ -23,10 +25,14 @@ def two_zone_local(): zone_mocks[0], "id", new_callable=PropertyMock(return_value=0) ), patch.object( zone_mocks[0], "name", new_callable=PropertyMock(return_value="Zone 0") + ), patch.object( + zone_mocks[0], "alarmed", new_callable=PropertyMock(return_value=False) ), patch.object( zone_mocks[1], "id", new_callable=PropertyMock(return_value=1) ), patch.object( zone_mocks[1], "name", new_callable=PropertyMock(return_value="Zone 1") + ), patch.object( + zone_mocks[1], "alarmed", new_callable=PropertyMock(return_value=False) ), patch( "homeassistant.components.risco.RiscoLocal.partitions", new_callable=PropertyMock(return_value={}), @@ -126,6 +132,8 @@ async def test_error_on_connect(hass, connect_with_error, local_config_entry): registry = er.async_get(hass) assert not registry.async_is_registered(FIRST_ENTITY_ID) assert not registry.async_is_registered(SECOND_ENTITY_ID) + assert not registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert not registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) async def test_local_setup(hass, two_zone_local, setup_risco_local): @@ -133,6 +141,8 @@ async def test_local_setup(hass, two_zone_local, setup_risco_local): registry = er.async_get(hass) assert registry.async_is_registered(FIRST_ENTITY_ID) assert registry.async_is_registered(SECOND_ENTITY_ID) + assert registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) registry = dr.async_get(hass) device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0_local")}) @@ -157,6 +167,7 @@ async def _check_local_state( new_callable=PropertyMock(return_value=bypassed), ): await callback(zone_id, zones[zone_id]) + await hass.async_block_till_done() expected_triggered = STATE_ON if triggered else STATE_OFF assert hass.states.get(entity_id).state == expected_triggered @@ -164,6 +175,22 @@ async def _check_local_state( assert hass.states.get(entity_id).attributes["zone_id"] == zone_id +async def _check_alarmed_local_state( + hass, zones, alarmed, entity_id, zone_id, callback +): + with patch.object( + zones[zone_id], + "alarmed", + new_callable=PropertyMock(return_value=alarmed), + ): + await callback(zone_id, zones[zone_id]) + await hass.async_block_till_done() + + expected_alarmed = STATE_ON if alarmed else STATE_OFF + assert hass.states.get(entity_id).state == expected_alarmed + assert hass.states.get(entity_id).attributes["zone_id"] == zone_id + + @pytest.fixture def _mock_zone_handler(): with patch("homeassistant.components.risco.RiscoLocal.add_zone_handler") as mock: @@ -204,6 +231,28 @@ async def test_local_states( ) +async def test_alarmed_local_states( + hass, two_zone_local, _mock_zone_handler, setup_risco_local +): + """Test the various alarm states.""" + callback = _mock_zone_handler.call_args.args[0] + + assert callback is not None + + await _check_alarmed_local_state( + hass, two_zone_local, True, FIRST_ALARMED_ENTITY_ID, 0, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, False, FIRST_ALARMED_ENTITY_ID, 0, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, True, SECOND_ALARMED_ENTITY_ID, 1, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, False, SECOND_ALARMED_ENTITY_ID, 1, callback + ) + + async def test_local_bypass(hass, two_zone_local, setup_risco_local): """Test bypassing a zone.""" with patch.object(two_zone_local[0], "bypass") as mock: diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py index cca6395c3177dd..6386a942cc420f 100644 --- a/tests/components/rtsp_to_webrtc/test_config_flow.py +++ b/tests/components/rtsp_to_webrtc/test_config_flow.py @@ -124,7 +124,9 @@ async def test_hassio_discovery(hass): "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -161,7 +163,9 @@ async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -181,7 +185,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -198,7 +204,9 @@ async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 764022f3501632..73ad642f7e7aae 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -32,6 +32,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index a105f388fc2907..f6879da90d69ae 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -16,8 +16,8 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock: None, + hass: HomeAssistant, enable_custom_integrations: None, ) -> None: """Test attributes to be excluded.""" diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 0ba9266a79d964..644ea84854aa8e 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -18,6 +18,7 @@ def return_config( username=None, password=None, headers=None, + unique_id=None, ) -> dict[str, dict[str, Any]]: """Return config.""" config = { @@ -44,6 +45,8 @@ def return_config( config["password"] = password if headers: config["headers"] = headers + if unique_id: + config["unique_id"] = unique_id return config diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index d8da22aada140e..aacd89b2eb9b4a 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -1,8 +1,10 @@ """The tests for the Scrape sensor platform.""" from __future__ import annotations +from datetime import datetime from unittest.mock import patch +from homeassistant.components.scrape.sensor import SCAN_INTERVAL from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -11,15 +13,18 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import MockRestData, return_config +from tests.common import async_fire_time_changed + DOMAIN = "scrape" @@ -89,6 +94,34 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT +async def test_scrape_unique_id(hass: HomeAssistant) -> None: + """Test Scrape sensor for unique id.""" + config = { + "sensor": return_config( + select=".current-temp h3", + name="Current Temp", + template="{{ value.split(':')[1] }}", + unique_id="very_unique_id", + ) + } + + mocker = MockRestData("test_scrape_uom_and_classes") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_temp") + assert state.state == "22.1" + + registry = er.async_get(hass) + entry = registry.async_get("sensor.current_temp") + assert entry + assert entry.unique_id == "very_unique_id" + + async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: """Test Scrape sensor with authentication.""" config = { @@ -155,12 +188,13 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: assert state assert state.state == "Current Version: 2021.12.10" - mocker.data = None - await async_update_entity(hass, "sensor.ha_version") + mocker.payload = "test_scrape_sensor_no_data" + async_fire_time_changed(hass, datetime.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() - assert mocker.data is None + state = hass.states.get("sensor.ha_version") assert state is not None - assert state.state == "Current Version: 2021.12.10" + assert state.state == STATE_UNAVAILABLE async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index b48a65275b7c31..09d2c3c70b1e51 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import script -from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED +from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, @@ -46,6 +46,12 @@ ENTITY_ID = "script.test" +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "script") + + async def test_passing_variables(hass): """Test different ways of passing in variables.""" mock_restore_cache(hass, ()) @@ -219,6 +225,129 @@ def event_handler(event): assert hass.services.has_service(script.DOMAIN, "test") +async def test_reload_unchanged_does_not_stop(hass, calls): + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + script.DOMAIN: { + "test": { + "sequence": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.script"}, + ], + } + } + } + assert await async_setup_component(hass, script.DOMAIN, config) + + assert hass.states.get(ENTITY_ID) is not None + assert hass.services.has_service(script.DOMAIN, "test") + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + # Start the script and wait for it to start + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(script.DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "script_config", + ( + { + "test": { + "sequence": [{"service": "test.script"}], + } + }, + # A script using templates + { + "test": { + "sequence": [{"service": "{{ 'test.script' }}"}], + } + }, + # A script using blueprint + { + "test": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.script", + }, + } + } + }, + # A script using blueprint with templated input + { + "test": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "{{ 'test.script' }}", + }, + } + } + }, + ), +) +async def test_reload_unchanged_script(hass, calls, script_config): + """Test an unmodified script is not reloaded.""" + with patch( + "homeassistant.components.script.ScriptEntity", wraps=ScriptEntity + ) as script_entity_init: + config = {script.DOMAIN: [script_config]} + assert await async_setup_component(hass, script.DOMAIN, config) + assert hass.states.get(ENTITY_ID) is not None + assert hass.services.has_service(script.DOMAIN, "test") + + assert script_entity_init.call_count == 1 + script_entity_init.reset_mock() + + # Start the script and wait for it to finish + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the scripts without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(script.DOMAIN, SERVICE_RELOAD, blocking=True) + + assert script_entity_init.call_count == 0 + script_entity_init.reset_mock() + + # Start the script and wait for it to start + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await hass.async_block_till_done() + assert len(calls) == 2 + + async def test_service_descriptions(hass): """Test that service descriptions are loaded and reloaded correctly.""" # Test 1: has "description" but no "fields" diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index a023212b82b6f8..ecbe554d9de52e 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -27,7 +27,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_exclude_attributes(hass, recorder_mock, calls): +async def test_exclude_attributes(recorder_mock, hass, calls): """Test automation registered attributes to be excluded.""" await hass.async_block_till_done() calls = [] diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 083caef34441ce..0598438029b065 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test select registered attributes to be excluded.""" await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index f9c3a7cb301330..224248c2d160a1 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -23,19 +23,30 @@ from homeassistant.components.sensibo.climate import ( ATTR_AC_INTEGRATION, ATTR_GEO_INTEGRATION, + ATTR_HIGH_TEMPERATURE_STATE, + ATTR_HIGH_TEMPERATURE_THRESHOLD, + ATTR_HORIZONTAL_SWING_MODE, ATTR_INDOOR_INTEGRATION, + ATTR_LIGHT, + ATTR_LOW_TEMPERATURE_STATE, + ATTR_LOW_TEMPERATURE_THRESHOLD, ATTR_MINUTES, ATTR_OUTDOOR_INTEGRATION, ATTR_SENSITIVITY, + ATTR_SMART_TYPE, + ATTR_TARGET_TEMPERATURE, SERVICE_ASSUME_STATE, + SERVICE_ENABLE_CLIMATE_REACT, SERVICE_ENABLE_PURE_BOOST, SERVICE_ENABLE_TIMER, + SERVICE_FULL_STATE, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_MODE, ATTR_STATE, ATTR_TEMPERATURE, SERVICE_TURN_OFF, @@ -916,3 +927,396 @@ async def test_climate_pure_boost( assert state2.state == "on" assert state3.state == "on" assert state4.state == "s" + + +async def test_climate_climate_react( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate react custom service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.hallway") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 0.2, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.3, + ATTR_SMART_TYPE: "temperature", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", + return_value={ + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "highTemperatureThreshold": 30.5, + "lowTemperatureState": { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "lowTemperatureThreshold": 5.5, + "type": "temperature", + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 5.5, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.5, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "temperature", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 5.5) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 30.5) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_low_state", + { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_high_state", + { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_climate_react") + state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") + state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") + state4 = hass.states.get("sensor.hallway_climate_react_type") + assert state1.state == "on" + assert state2.state == "5.5" + assert state3.state == "30.5" + assert state4.state == "temperature" + + +async def test_climate_climate_react_fahrenheit( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate react custom service with fahrenheit.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.hallway") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", + return_value={ + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "highTemperatureThreshold": 77, + "lowTemperatureState": { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "lowTemperatureThreshold": 32, + "type": "temperature", + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 32.0, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 77.0, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "temperature", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 0) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 25) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_low_state", + { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_high_state", + { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_climate_react") + state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") + state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") + state4 = hass.states.get("sensor.hallway_climate_react_type") + assert state1.state == "on" + assert state2.state == "0" + assert state3.state == "25" + assert state4.state == "temperature" + + +async def test_climate_full_ac_state( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Full AC state service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.hallway") + assert state_climate.state == "heat" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_FULL_STATE, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_TARGET_TEMPERATURE: 22, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", + return_value={"result": {"status": "Success"}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_FULL_STATE, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_MODE: "cool", + ATTR_TARGET_TEMPERATURE: 22, + ATTR_FAN_MODE: "high", + ATTR_SWING_MODE: "stopped", + ATTR_HORIZONTAL_SWING_MODE: "stopped", + ATTR_LIGHT: "on", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "cool") + monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "target_temp", 22) + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", "high") + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", "stopped") + monkeypatch.setattr( + get_data.parsed["ABC999111"], "horizontal_swing_mode", "stopped" + ) + monkeypatch.setattr(get_data.parsed["ABC999111"], "light_mode", "on") + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state = hass.states.get("climate.hallway") + + assert state.state == "cool" + assert state.attributes["temperature"] == 22 diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 426416ae2b99fc..676e9f1f73d6e2 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData from pytest import MonkeyPatch @@ -16,6 +16,7 @@ async def test_sensor( hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, load_int: ConfigEntry, monkeypatch: MonkeyPatch, get_data: SensiboData, @@ -25,9 +26,11 @@ async def test_sensor( state1 = hass.states.get("sensor.hallway_motion_sensor_battery_voltage") state2 = hass.states.get("sensor.kitchen_pm2_5") state3 = hass.states.get("sensor.kitchen_pure_sensitivity") + state4 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") assert state1.state == "3000" assert state2.state == "1" assert state3.state == "n" + assert state4.state == "0.0" assert state2.attributes == { "state_class": "measurement", "unit_of_measurement": "µg/m³", @@ -35,6 +38,20 @@ async def test_sensor( "icon": "mdi:air-filter", "friendly_name": "Kitchen PM2.5", } + assert state4.attributes == { + "device_class": "temperature", + "friendly_name": "Hallway Climate React low temperature threshold", + "state_class": "measurement", + "unit_of_measurement": "°C", + "on": True, + "targetTemperature": 21, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25", 2) diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index 2b99fa2e227725..3ff0e52a0f885c 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -223,3 +223,113 @@ async def test_switch_command_failure( }, blocking=True, ) + + +async def test_switch_climate_react( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch for climate react.""" + + state1 = hass.states.get("switch.hallway_climate_react") + assert state1.state == STATE_OFF + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_enable_climate_react", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + state1 = hass.states.get("switch.hallway_climate_react") + assert state1.state == STATE_ON + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_enable_climate_react", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", False) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_climate_react") + assert state1.state == STATE_OFF + + +async def test_switch_climate_react_no_data( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch for climate react.""" + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", None) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_climate_react") + assert state1.state == STATE_OFF + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index a9ea9ce0fbecf6..e168a1c22712f5 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -11,7 +11,9 @@ LENGTH_CENTIMETERS, LENGTH_INCHES, LENGTH_KILOMETERS, + LENGTH_METERS, LENGTH_MILES, + LENGTH_YARD, MASS_GRAMS, MASS_OUNCES, PRESSURE_HPA, @@ -35,7 +37,7 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import mock_restore_cache_with_extra_data @@ -43,8 +45,8 @@ @pytest.mark.parametrize( "unit_system,native_unit,state_unit,native_value,state_value", [ - (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, 100), - (IMPERIAL_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, 100), + (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, 100), + (US_CUSTOMARY_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, 100), (METRIC_SYSTEM, TEMP_FAHRENHEIT, TEMP_CELSIUS, 100, 38), (METRIC_SYSTEM, TEMP_CELSIUS, TEMP_CELSIUS, 38, 38), ], @@ -565,11 +567,11 @@ async def test_custom_unit( SensorDeviceClass.VOLUME, ), ( - VOLUME_FLUID_OUNCE, - VOLUME_LITERS, VOLUME_LITERS, - 78, + VOLUME_FLUID_OUNCE, + VOLUME_FLUID_OUNCE, 2.3, + 77.8, SensorDeviceClass.VOLUME, ), ( @@ -661,3 +663,267 @@ async def test_custom_unit_change( state = hass.states.get(entity0.entity_id) assert float(state.state) == approx(float(native_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, automatic_unit, suggested_unit, custom_unit, native_value, automatic_value, suggested_value, custom_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_METERS, + LENGTH_YARD, + 1000, + 621, + 1000000, + 1093613, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority( + hass, + enable_custom_integrations, + unit_system, + native_unit, + automatic_unit, + suggested_unit, + custom_unit, + native_value, + automatic_value, + suggested_value, + custom_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + ) + entity1 = platform.ENTITIES["1"] + + platform.ENTITIES["2"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity2 = platform.ENTITIES["2"] + + platform.ENTITIES["3"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + ) + entity3 = platform.ENTITIES["3"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow automatic unit conversion + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(automatic_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit + # Assert the automatic unit conversion is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": automatic_unit} + } + + # Unregistered entity -> Follow native unit + state = hass.states.get(entity1.entity_id) + assert float(state.state) == approx(float(native_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + # Registered entity with suggested unit + state = hass.states.get(entity2.entity_id) + assert float(state.state) == approx(float(suggested_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity2.entity_id) + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": suggested_unit} + } + + # Unregistered entity with suggested unit + state = hass.states.get(entity3.entity_id) + assert float(state.state) == approx(float(suggested_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + + # Set a custom unit, this should have priority over the automatic unit conversion + entity_registry.async_update_entity_options( + entity0.entity_id, "sensor", {"unit_of_measurement": custom_unit} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(custom_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + + entity_registry.async_update_entity_options( + entity2.entity_id, "sensor", {"unit_of_measurement": custom_unit} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity2.entity_id) + assert float(state.state) == approx(float(custom_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, original_unit, suggested_unit, native_value, original_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_YARD, + LENGTH_METERS, + 1000, + 1093613, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority_suggested_unit_change( + hass, + enable_custom_integrations, + unit_system, + native_unit, + original_unit, + suggested_unit, + native_value, + original_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor.private", + {"suggested_unit_of_measurement": original_unit}, + ) + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique_2") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor.private", + {"suggested_unit_of_measurement": original_unit}, + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity1 = platform.ENTITIES["1"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow automatic unit conversion the first time the entity was seen + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + + # Registered entity -> Follow suggested unit the first time the entity was seen + state = hass.states.get(entity1.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, original_unit, native_value, original_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_MILES, + 1000, + 621, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority_legacy_conversion_removed( + hass, + enable_custom_integrations, + unit_system, + native_unit, + original_unit, + native_value, + original_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=original_unit + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0a72dcf6fcdf0b..05f8bd40597d18 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,6 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name -from datetime import timedelta +from datetime import datetime, timedelta import math from statistics import mean from unittest.mock import patch @@ -9,10 +9,15 @@ from pytest import approx from homeassistant import loader -from homeassistant.components.recorder import history +from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, history from homeassistant.components.recorder.db_schema import StatisticsMeta -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMetaData, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( + async_import_statistics, get_metadata, list_statistic_ids, statistics_during_period, @@ -21,7 +26,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import ( async_recorder_block_till_done, @@ -398,18 +403,18 @@ def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes) @pytest.mark.parametrize( "units, device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - (IMPERIAL_SYSTEM, "distance", "m", "m", "m", "distance", 1), - (IMPERIAL_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), - (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), - (IMPERIAL_SYSTEM, "energy", "Wh", "Wh", "Wh", "energy", 1), - (IMPERIAL_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), - (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", "ft³", "volume", 1), - (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), - (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), - (IMPERIAL_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), - (IMPERIAL_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), - (IMPERIAL_SYSTEM, "weight", "g", "g", "g", "mass", 1), - (IMPERIAL_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), + (US_CUSTOMARY_SYSTEM, "distance", "m", "m", "m", "distance", 1), + (US_CUSTOMARY_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), + (US_CUSTOMARY_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), + (US_CUSTOMARY_SYSTEM, "energy", "Wh", "Wh", "Wh", "energy", 1), + (US_CUSTOMARY_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "gas", "ft³", "ft³", "ft³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), + (US_CUSTOMARY_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), + (US_CUSTOMARY_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "weight", "g", "g", "g", "mass", 1), + (US_CUSTOMARY_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), (METRIC_SYSTEM, "distance", "m", "m", "m", "distance", 1), (METRIC_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -425,9 +430,9 @@ def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes) ], ) async def test_compile_hourly_sum_statistics_amount( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, units, state_class, @@ -1907,6 +1912,9 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): ("battery", "%", "cats", None, 13.050847, -10, 30), ("battery", None, "cats", None, 13.050847, -10, 30), (None, "kW", "Wh", "power", 13.050847, -10, 30), + # Can't downgrade from ft³ to ft3 or from m³ to m3 + (None, "ft³", "ft3", "volume", 13.050847, -10, 30), + (None, "m³", "m3", "volume", 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_1( @@ -2187,6 +2195,208 @@ def test_compile_hourly_statistics_changing_units_3( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, unit_class2, mean, mean2, min, max", + [ + (None, "RPM", "rpm", None, None, 13.050847, 13.333333, -10, 30), + (None, "rpm", "RPM", None, None, 13.050847, 13.333333, -10, 30), + (None, "ft3", "ft³", None, "volume", 13.050847, 13.333333, -10, 30), + (None, "m3", "m³", None, "volume", 13.050847, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_1( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + unit_class2, + mean, + mean2, + min, + max, +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + assert "can not be converted to the unit of previously" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit2, + "unit_class": unit_class2, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=10) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=15)), + "mean": approx(mean2), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, mean, min, max", + [ + (None, "RPM", "rpm", None, 13.333333, -10, 30), + (None, "rpm", "RPM", None, 13.333333, -10, 30), + (None, "ft3", "ft³", None, 13.333333, -10, 30), + (None, "m3", "m³", None, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_2( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + mean, + min, + max, +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero + timedelta(seconds=30 * 5)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" not in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 5) + ), + "end": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 15) + ), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class, state_unit, statistic_unit, unit_class, mean1, mean2, min, max", [ @@ -3097,12 +3307,18 @@ def set_state(entity_id, state, **kwargs): @pytest.mark.parametrize( "units, attributes, unit, unit2, supported_unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "°F", + "K", + "K, °C, °F", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", @@ -3117,10 +3333,17 @@ def set_state(entity_id, state, **kwargs): ), ], ) -async def test_validate_statistics_unit_change_device_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit +async def test_validate_unit_change_convertible( + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit2, supported_unit ): - """Test validate_statistics.""" + """Test validate_statistics. + + This tests what happens if a sensor is first recorded in a unit which supports unit + conversion, and the unit is then changed to a unit which can and can not be + converted to the original unit. + + The test also asserts that the sensor's device class is ignored. + """ id = 1 def next_id(): @@ -3190,7 +3413,7 @@ async def assert_validation_result(client, expected_result): await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3202,11 +3425,11 @@ async def assert_validation_result(client, expected_result): await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # Remove the state - empty response + # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") expected = { "sensor.test": [ @@ -3220,15 +3443,18 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( - "units, attributes, valid_units", + "units, attributes", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES), ], ) -async def test_validate_statistics_unit_change_device_class_2( - hass, hass_ws_client, recorder_mock, units, attributes, valid_units +async def test_validate_statistics_unit_ignore_device_class( + recorder_mock, hass, hass_ws_client, units, attributes ): - """Test validate_statistics.""" + """Test validate_statistics. + + The test asserts that the sensor's device class is ignored. + """ id = 1 def next_id(): @@ -3273,12 +3499,18 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( "units, attributes, unit, unit2, supported_unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "°F", + "K", + "K, °C, °F", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", @@ -3294,9 +3526,14 @@ async def assert_validation_result(client, expected_result): ], ) async def test_validate_statistics_unit_change_no_device_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit2, supported_unit ): - """Test validate_statistics.""" + """Test validate_statistics. + + This tests what happens if a sensor is first recorded in a unit which supports unit + conversion, and the unit is then changed to a unit which can and can not be + converted to the original unit. + """ id = 1 attributes = dict(attributes) attributes.pop("device_class") @@ -3324,14 +3561,14 @@ async def assert_validation_result(client, expected_result): # No statistics, no state - empty response await assert_validation_result(client, {}) - # No statistics, unit in state matching device class - empty response + # No statistics, sensor state set - empty response hass.states.async_set( "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} ) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # No statistics, unit in state not matching device class - empty response + # No statistics, sensor state set to an incompatible unit - empty response hass.states.async_set( "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} ) @@ -3368,7 +3605,7 @@ async def assert_validation_result(client, expected_result): await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3380,11 +3617,11 @@ async def assert_validation_result(client, expected_result): await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # Remove the state - empty response + # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") expected = { "sensor.test": [ @@ -3400,11 +3637,11 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_unsupported_state_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3464,11 +3701,11 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_no_longer_recorded( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3525,11 +3762,11 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_not_recorded( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3583,11 +3820,11 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_removed( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3639,11 +3876,14 @@ async def assert_validation_result(client, expected_result): @pytest.mark.parametrize( - "attributes", - [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], + "attributes, unit1, unit2", + [ + (BATTERY_SENSOR_ATTRIBUTES, "%", "dogs"), + (NONE_SENSOR_ATTRIBUTES, None, "dogs"), + ], ) async def test_validate_statistics_unit_change_no_conversion( - hass, recorder_mock, hass_ws_client, attributes + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2 ): """Test validate_statistics.""" id = 1 @@ -3682,12 +3922,14 @@ async def assert_statistic_ids(expected_result): await assert_validation_result(client, {}) # No statistics, original unit - empty response - hass.states.async_set("sensor.test", 10, attributes=attributes) + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) await assert_validation_result(client, {}) # No statistics, changed unit - empty response hass.states.async_set( - "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": unit2}} ) await assert_validation_result(client, {}) @@ -3697,32 +3939,34 @@ async def assert_statistic_ids(expected_result): await async_recorder_block_till_done(hass) await assert_statistic_ids([]) - # No statistics, changed unit - empty response + # No statistics, original unit - empty response hass.states.async_set( - "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit1}} ) await assert_validation_result(client, {}) - # Run statistics one hour later, only the "dogs" state will be considered + # Run statistics one hour later, only the state with unit1 will be considered await async_recorder_block_till_done(hass) do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_statistic_ids( - [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) await assert_validation_result(client, {}) - # Change back to original unit - expect error - hass.states.async_set("sensor.test", 13, attributes=attributes) + # Change unit - expect error + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) await async_recorder_block_till_done(hass) expected = { "sensor.test": [ { "data": { - "metadata_unit": "dogs", - "state_unit": attributes.get("unit_of_measurement"), + "metadata_unit": unit1, + "state_unit": unit2, "statistic_id": "sensor.test", - "supported_unit": "dogs", + "supported_unit": unit1, }, "type": "units_changed", } @@ -3730,16 +3974,16 @@ async def assert_statistic_ids(expected_result): } await assert_validation_result(client, expected) - # Changed unit - empty response + # Original unit - empty response hass.states.async_set( - "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": unit1}} ) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response await async_recorder_block_till_done(hass) - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3756,6 +4000,226 @@ async def assert_statistic_ids(expected_result): await assert_validation_result(client, expected) +@pytest.mark.parametrize( + "attributes, unit1, unit2", + [ + (NONE_SENSOR_ATTRIBUTES, "m3", "m³"), + (NONE_SENSOR_ATTRIBUTES, "rpm", "RPM"), + (NONE_SENSOR_ATTRIBUTES, "RPM", "rpm"), + ], +) +async def test_validate_statistics_unit_change_equivalent_units( + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2 +): + """Test validate_statistics. + + This tests no validation issue is created when a sensor's unit changes to an + equivalent unit. + """ + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) + await assert_validation_result(client, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + + # Units changed to an equivalent unit - empty response + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) + await assert_validation_result(client, {}) + + # Run statistics one hour later, metadata will be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit2}] + ) + await assert_validation_result(client, {}) + + +@pytest.mark.parametrize( + "attributes, unit1, unit2, supported_unit", + [ + (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "L, fl. oz., ft³, gal, mL, m³"), + ], +) +async def test_validate_statistics_unit_change_equivalent_units_2( + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2, supported_unit +): + """Test validate_statistics. + + This tests a validation issue is created when a sensor's unit changes to an + equivalent unit which is not known to the unit converters. + """ + + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) + await assert_validation_result(client, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + + # Units changed to an equivalent unit which is not known by the unit converters + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) + expected = { + "sensor.test": [ + { + "data": { + "metadata_unit": unit1, + "state_unit": unit2, + "statistic_id": "sensor.test", + "supported_unit": supported_unit, + }, + "type": "units_changed", + } + ], + } + await assert_validation_result(client, expected) + + # Run statistics one hour later, metadata will not be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + await assert_validation_result(client, expected) + + +async def test_validate_statistics_other_domain(recorder_mock, hass, hass_ws_client): + """Test sensor does not raise issues for statistics for other domains.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # Create statistics for another domain + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": None, + "source": RECORDER_DOMAIN, + "statistic_id": "number.test", + "unit_of_measurement": None, + } + statistics: StatisticData = { + "last_reset": None, + "max": None, + "mean": None, + "min": None, + "start": datetime(2020, 10, 6, tzinfo=dt_util.UTC), + "state": None, + "sum": None, + } + async_import_statistics(hass, metadata, (statistics,)) + await async_recorder_block_till_done(hass) + + # We should not get complains about the missing number entity + await assert_validation_result(client, {}) + + def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 3c502c81deb4a9..a3c571d71773d8 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -1 +1,28 @@ """Tests for the Shelly integration.""" +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_MAC = "123456789ABC" + + +async def init_integration( + hass: HomeAssistant, gen: int, model="SHSW-25", sleep_period=0 +) -> MockConfigEntry: + """Set up the Shelly integration in Home Assistant.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: sleep_period, + "model": model, + "gen": gen, + } + + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 49e86e118e3c29..cd23cc240c5363 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,38 +1,24 @@ """Test configuration for Shelly.""" from unittest.mock import AsyncMock, Mock, patch +from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import pytest -from homeassistant.components.shelly import ( - BlockDeviceWrapper, - RpcDeviceWrapper, - RpcPollingWrapper, - ShellyDeviceRestWrapper, -) from homeassistant.components.shelly.const import ( - BLOCK, - DATA_CONFIG_ENTRY, - DOMAIN, EVENT_SHELLY_CLICK, - REST, REST_SENSORS_UPDATE_INTERVAL, - RPC, - RPC_POLL, ) -from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_capture_events, - async_mock_service, - mock_device_registry, -) +from . import MOCK_MAC + +from tests.common import async_capture_events, async_mock_service, mock_device_registry MOCK_SETTINGS = { "name": "Test name", "mode": "relay", "device": { - "mac": "test-mac", + "mac": MOCK_MAC, "hostname": "test-host", "type": "SHSW-25", "num_outputs": 2, @@ -43,6 +29,36 @@ "rollers": [{"positioning": True}], } + +def mock_light_set_state( + turn="on", + mode="color", + red=45, + green=55, + blue=65, + white=70, + gain=19, + temp=4050, + brightness=50, + effect=0, + transition=0, +): + """Mock light block set_state.""" + return { + "ison": turn == "on", + "mode": mode, + "red": red, + "green": green, + "blue": blue, + "white": white, + "gain": gain, + "temp": temp, + "brightness": brightness, + "effect": effect, + "transition": transition, + } + + MOCK_BLOCKS = [ Mock( sensor_ids={"inputEvent": "S", "inputEventCnt": 2}, @@ -61,6 +77,15 @@ } ), ), + Mock( + sensor_ids={}, + channel="0", + output=mock_light_set_state()["ison"], + colorTemp=mock_light_set_state()["temp"], + **mock_light_set_state(), + type="light", + set_state=AsyncMock(side_effect=mock_light_set_state), + ), ] MOCK_CONFIG = { @@ -74,7 +99,7 @@ } MOCK_SHELLY_COAP = { - "mac": "test-mac", + "mac": MOCK_MAC, "auth": False, "fw": "20201124-092854/v1.9.0@57ac4ad8", "num_outputs": 2, @@ -83,7 +108,7 @@ MOCK_SHELLY_RPC = { "name": "Test Gen2", "id": "shellyplus2pm-123456789abc", - "mac": "123456789ABC", + "mac": MOCK_MAC, "model": "SNSW-002P16EU", "gen": 2, "fw_id": "20220830-130540/0.11.0-gfa1bc37", @@ -121,7 +146,20 @@ @pytest.fixture(autouse=True) def mock_coap(): """Mock out coap.""" - with patch("homeassistant.components.shelly.utils.get_coap_context"): + with patch( + "homeassistant.components.shelly.utils.COAP", + return_value=Mock( + initialize=AsyncMock(), + close=Mock(), + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_ws_server(): + """Mock out ws_server.""" + with patch("homeassistant.components.shelly.utils.get_ws_context"): yield @@ -144,80 +182,47 @@ def events(hass): @pytest.fixture -async def coap_wrapper(hass): - """Setups a coap wrapper with mocked device.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 0, "model": "SHSW-25", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=MOCK_BLOCKS, - settings=MOCK_SETTINGS, - shelly=MOCK_SHELLY_COAP, - status=MOCK_STATUS_COAP, - firmware_version="some fw string", - update=AsyncMock(), - update_status=AsyncMock(), - trigger_ota_update=AsyncMock(), - trigger_reboot=AsyncMock(), - initialized=True, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - REST - ] = ShellyDeviceRestWrapper(hass, device, config_entry) - - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - wrapper.async_setup() - - return wrapper +async def mock_block_device(): + """Mock block (Gen1, CoAP) device.""" + with patch("aioshelly.block_device.BlockDevice.create") as block_device_mock: + + def update(): + block_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + + device = Mock( + spec=BlockDevice, + blocks=MOCK_BLOCKS, + settings=MOCK_SETTINGS, + shelly=MOCK_SHELLY_COAP, + status=MOCK_STATUS_COAP, + firmware_version="some fw string", + initialized=True, + ) + block_device_mock.return_value = device + block_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield block_device_mock.return_value @pytest.fixture -async def rpc_wrapper(hass): - """Setups a rpc wrapper with mocked device.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2, "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - call_rpc=AsyncMock(), - config=MOCK_CONFIG, - event={}, - shelly=MOCK_SHELLY_RPC, - status=MOCK_STATUS_RPC, - firmware_version="some fw string", - update=AsyncMock(), - trigger_ota_update=AsyncMock(), - trigger_reboot=AsyncMock(), - initialized=True, - shutdown=AsyncMock(), - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - RPC_POLL - ] = RpcPollingWrapper(hass, config_entry, device) - - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - RPC - ] = RpcDeviceWrapper(hass, config_entry, device) - wrapper.async_setup() - - return wrapper +async def mock_rpc_device(): + """Mock rpc (Gen2, Websocket) device.""" + with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: + + def update(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + status=MOCK_STATUS_RPC, + firmware_version="some fw string", + initialized=True, + ) + + rpc_device_mock.return_value = device + rpc_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield rpc_device_mock.return_value diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 8bbae677fb6359..bd20be7c645778 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,33 +1,17 @@ """Tests for Shelly button platform.""" from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from . import init_integration -async def test_block_button(hass: HomeAssistant, coap_wrapper): - """Test block device reboot button.""" - assert coap_wrapper - entity_registry = async_get(hass) - entity_registry.async_get_or_create( - BUTTON_DOMAIN, - DOMAIN, - "test_name_reboot", - suggested_object_id="test_name_reboot", - disabled_by=None, - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, BUTTON_DOMAIN) - ) - await hass.async_block_till_done() +async def test_block_button(hass: HomeAssistant, mock_block_device): + """Test block device reboot button.""" + await init_integration(hass, 1) # reboot button - state = hass.states.get("button.test_name_reboot") - - assert state - assert state.state == STATE_UNKNOWN + assert hass.states.get("button.test_name_reboot").state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, @@ -35,33 +19,15 @@ async def test_block_button(hass: HomeAssistant, coap_wrapper): {ATTR_ENTITY_ID: "button.test_name_reboot"}, blocking=True, ) - await hass.async_block_till_done() - assert coap_wrapper.device.trigger_reboot.call_count == 1 + assert mock_block_device.trigger_reboot.call_count == 1 -async def test_rpc_button(hass: HomeAssistant, rpc_wrapper): +async def test_rpc_button(hass: HomeAssistant, mock_rpc_device): """Test rpc device OTA button.""" - assert rpc_wrapper - - entity_registry = async_get(hass) - entity_registry.async_get_or_create( - BUTTON_DOMAIN, - DOMAIN, - "test_name_reboot", - suggested_object_id="test_name_reboot", - disabled_by=None, - ) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, BUTTON_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) # reboot button - state = hass.states.get("button.test_name_reboot") - - assert state - assert state.state == STATE_UNKNOWN + assert hass.states.get("button.test_name_reboot").state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, @@ -69,5 +35,4 @@ async def test_rpc_button(hass: HomeAssistant, rpc_wrapper): {ATTR_ENTITY_ID: "button.test_name_reboot"}, blocking=True, ) - await hass.async_block_till_done() - assert rpc_wrapper.device.trigger_reboot.call_count == 1 + assert mock_rpc_device.trigger_reboot.call_count == 1 diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index a761fe7836e397..ad28ffbd4f0359 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -1,10 +1,11 @@ """Test the Shelly config flow.""" -import asyncio -from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch -import aiohttp -import aioshelly +from aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) import pytest from homeassistant import config_entries, data_entry_flow @@ -207,7 +208,7 @@ async def test_form_auth(hass, test_data): @pytest.mark.parametrize( - "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")] + "error", [(DeviceConnectionError, "cannot_connect"), (ValueError, "unknown")] ) async def test_form_errors_get_info(hass, error): """Test we handle errors.""" @@ -324,7 +325,7 @@ async def test_form_missing_model_key_zeroconf(hass, caplog): @pytest.mark.parametrize( - "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")] + "error", [(DeviceConnectionError, "cannot_connect"), (ValueError, "unknown")] ) async def test_form_errors_test_connection(hass, error): """Test we handle errors.""" @@ -431,10 +432,7 @@ async def test_form_firmware_unsupported(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "aioshelly.common.get_info", - side_effect=aioshelly.exceptions.FirmwareUnsupported, - ): + with patch("aioshelly.common.get_info", side_effect=FirmwareUnsupported): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -447,15 +445,8 @@ async def test_form_firmware_unsupported(hass): @pytest.mark.parametrize( "error", [ - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.BAD_REQUEST), - "cannot_connect", - ), - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.UNAUTHORIZED), - "invalid_auth", - ), - (asyncio.TimeoutError, "cannot_connect"), + (InvalidAuthError, "invalid_auth"), + (DeviceConnectionError, "cannot_connect"), (ValueError, "unknown"), ], ) @@ -490,15 +481,8 @@ async def test_form_auth_errors_test_connection_gen1(hass, error): @pytest.mark.parametrize( "error", [ - ( - aioshelly.exceptions.JSONRPCError(code=400), - "cannot_connect", - ), - ( - aioshelly.exceptions.InvalidAuthError(code=401), - "invalid_auth", - ), - (asyncio.TimeoutError, "cannot_connect"), + (DeviceConnectionError, "cannot_connect"), + (InvalidAuthError, "invalid_auth"), (ValueError, "unknown"), ], ) @@ -647,20 +631,8 @@ async def test_zeroconf_sleeping_device(hass): assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - "error", - [ - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.BAD_REQUEST), - "cannot_connect", - ), - (asyncio.TimeoutError, "cannot_connect"), - ], -) -async def test_zeroconf_sleeping_device_error(hass, error): +async def test_zeroconf_sleeping_device_error(hass): """Test sleeping device configuration via zeroconf with error.""" - exc = error - with patch( "aioshelly.common.get_info", return_value={ @@ -671,7 +643,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): }, ), patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + new=AsyncMock(side_effect=DeviceConnectionError), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -708,10 +680,7 @@ async def test_zeroconf_already_configured(hass): async def test_zeroconf_firmware_unsupported(hass): """Test we abort if device firmware is unsupported.""" - with patch( - "aioshelly.common.get_info", - side_effect=aioshelly.exceptions.FirmwareUnsupported, - ): + with patch("aioshelly.common.get_info", side_effect=FirmwareUnsupported): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -724,7 +693,7 @@ async def test_zeroconf_firmware_unsupported(hass): async def test_zeroconf_cannot_connect(hass): """Test we get the form.""" - with patch("aioshelly.common.get_info", side_effect=asyncio.TimeoutError): + with patch("aioshelly.common.get_info", side_effect=DeviceConnectionError): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -840,21 +809,13 @@ async def test_reauth_successful(hass, test_data): @pytest.mark.parametrize( "test_data", [ - ( - 1, - {"username": "test user", "password": "test1 password"}, - aioshelly.exceptions.InvalidAuthError(code=HTTPStatus.UNAUTHORIZED.value), - ), - ( - 2, - {"password": "test2 password"}, - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.UNAUTHORIZED), - ), + (1, {"username": "test user", "password": "test1 password"}), + (2, {"password": "test2 password"}), ], ) async def test_reauth_unsuccessful(hass, test_data): """Test reauthentication flow failed.""" - gen, user_input, exc = test_data + gen, user_input = test_data entry = MockConfigEntry( domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} ) @@ -865,9 +826,10 @@ async def test_reauth_unsuccessful(hass, test_data): return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, ), patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + new=AsyncMock(side_effect=InvalidAuthError), ), patch( - "aioshelly.rpc_device.RpcDevice.create", new=AsyncMock(side_effect=exc) + "aioshelly.rpc_device.RpcDevice.create", + new=AsyncMock(side_effect=InvalidAuthError), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -889,11 +851,7 @@ async def test_reauth_unsuccessful(hass, test_data): @pytest.mark.parametrize( "error", - [ - asyncio.TimeoutError, - aiohttp.ClientError, - aioshelly.exceptions.FirmwareUnsupported, - ], + [DeviceConnectionError, FirmwareUnsupported], ) async def test_reauth_get_info_error(hass, error): """Test reauthentication flow failed with error in get_info().""" diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index ab8c9a9a876b8e..51fef7dc030a9d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly cover platform.""" from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, @@ -13,20 +13,16 @@ STATE_OPENING, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.helpers.entity_component import async_update_entity + +from . import init_integration ROLLER_BLOCK_ID = 1 -async def test_block_device_services(hass, coap_wrapper, monkeypatch): +async def test_block_device_services(hass, mock_block_device, monkeypatch): """Test block device cover services.""" - assert coap_wrapper - - monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setitem(mock_block_device.settings, "mode", "roller") + await init_integration(hass, 1) await hass.services.async_call( COVER_DOMAIN, @@ -62,46 +58,28 @@ async def test_block_device_services(hass, coap_wrapper, monkeypatch): assert hass.states.get("cover.test_name").state == STATE_CLOSED -async def test_block_device_update(hass, coap_wrapper, monkeypatch): +async def test_block_device_update(hass, mock_block_device, monkeypatch): """Test block device update.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) + await init_integration(hass, 1) - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) - await async_update_entity(hass, "cover.test_name") - await hass.async_block_till_done() assert hass.states.get("cover.test_name").state == STATE_CLOSED - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) - await async_update_entity(hass, "cover.test_name") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) + mock_block_device.mock_update() assert hass.states.get("cover.test_name").state == STATE_OPEN -async def test_block_device_no_roller_blocks(hass, coap_wrapper, monkeypatch): +async def test_block_device_no_roller_blocks(hass, mock_block_device, monkeypatch): """Test block device without roller blocks.""" - assert coap_wrapper - - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "type", None) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "type", None) + await init_integration(hass, 1) assert hass.states.get("cover.test_name") is None -async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): """Test RPC device cover services.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) await hass.services.async_call( COVER_DOMAIN, @@ -112,81 +90,57 @@ async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): state = hass.states.get("cover.test_cover_0") assert state.attributes[ATTR_CURRENT_POSITION] == 50 - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "opening") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "opening") await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPENING - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closing") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closing") await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_CLOSING - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED -async def test_rpc_device_no_cover_keys(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_no_cover_keys(hass, mock_rpc_device, monkeypatch): """Test RPC device without cover keys.""" - assert rpc_wrapper - - monkeypatch.delitem(rpc_wrapper.device.status, "cover:0") - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0") is None -async def test_rpc_device_update(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_update(hass, mock_rpc_device, monkeypatch): """Test RPC device update.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() - - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "open") - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "open") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPEN -async def test_rpc_device_no_position_control(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_no_position_control(hass, mock_rpc_device, monkeypatch): """Test RPC device with no position control.""" - assert rpc_wrapper - - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "pos_control", False) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() - - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "pos_control", False) + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_OPEN diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index b638032e96ef6e..d5881696bf6ddf 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -1,6 +1,4 @@ """The tests for Shelly device triggers.""" -from unittest.mock import AsyncMock, Mock - import pytest from homeassistant.components import automation @@ -8,20 +6,23 @@ from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.shelly import BlockDeviceWrapper from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, - BLOCK, CONF_SUBTYPE, - DATA_CONFIG_ENTRY, DOMAIN, EVENT_SHELLY_CLICK, ) from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.helpers import device_registry +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component +from . import init_integration + from tests.common import ( MockConfigEntry, assert_lists_same, @@ -39,48 +40,52 @@ ], ) async def test_get_triggers_block_device( - hass, coap_wrapper, monkeypatch, button_type, is_valid + hass, mock_block_device, monkeypatch, button_type, is_valid ): """Test we get the expected triggers from a shelly block device.""" - assert coap_wrapper - monkeypatch.setitem( - coap_wrapper.device.settings, + mock_block_device.settings, "relays", [ {"btn_type": button_type}, {"btn_type": "toggle"}, ], ) + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [] if is_valid: expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, - CONF_TYPE: type, + CONF_TYPE: type_, CONF_SUBTYPE: "button1", "metadata": {}, } - for type in ["single", "long"] + for type_ in ["single", "long"] ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_rpc_device(hass, rpc_wrapper): +async def test_get_triggers_rpc_device(hass, mock_rpc_device): """Test we get the expected triggers from a shelly RPC device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, CONF_TYPE: type, CONF_SUBTYPE: "button1", @@ -90,43 +95,22 @@ async def test_get_triggers_rpc_device(hass, rpc_wrapper): ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, rpc_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_button(hass): +async def test_get_triggers_button(hass, mock_block_device): """Test we get the expected triggers from a shelly button.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 43200, "model": "SHBTN-1", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=None, - settings=None, - shelly=None, - update=AsyncMock(), - initialized=False, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - coap_wrapper.async_setup() + entry = await init_integration(hass, 1, model="SHBTN-1") + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, CONF_TYPE: type, CONF_SUBTYPE: "button", @@ -136,51 +120,33 @@ async def test_get_triggers_button(hass): ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_non_initialized_devices(hass): +async def test_get_triggers_non_initialized_devices( + hass, mock_block_device, monkeypatch +): """Test we get the empty triggers for non-initialized devices.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 43200, "model": "SHDW-2", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=None, - settings=None, - shelly=None, - update=AsyncMock(), - initialized=False, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - coap_wrapper.async_setup() + monkeypatch.setattr(mock_block_device, "initialized", False) + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper): +async def test_get_triggers_for_invalid_device_id(hass, device_reg, mock_block_device): """Test error raised for invalid shelly device_id.""" - assert coap_wrapper + await init_integration(hass, 1) config_entry = MockConfigEntry(domain=DOMAIN, data={}) config_entry.add_to_hass(hass) invalid_device = device_reg.async_get_or_create( @@ -194,9 +160,11 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper ) -async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): +async def test_if_fires_on_click_event_block_device(hass, calls, mock_block_device): """Test for click_event trigger firing for block device.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -207,7 +175,7 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single", CONF_SUBTYPE: "button1", }, @@ -221,7 +189,7 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): ) message = { - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1, } @@ -232,9 +200,11 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" -async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): +async def test_if_fires_on_click_event_rpc_device(hass, calls, mock_rpc_device): """Test for click_event trigger firing for rpc device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -245,7 +215,7 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single_push", CONF_SUBTYPE: "button1", }, @@ -259,7 +229,7 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): ) message = { - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, ATTR_CLICK_TYPE: "single_push", ATTR_CHANNEL: 1, } @@ -270,9 +240,9 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): assert calls[0].data["some"] == "test_trigger_single_push" -async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper): +async def test_validate_trigger_block_device_not_ready(hass, calls, mock_block_device): """Test validate trigger config when block device is not ready.""" - assert coap_wrapper + await init_integration(hass, 1) assert await async_setup_component( hass, @@ -307,10 +277,8 @@ async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper assert calls[0].data["some"] == "test_trigger_single_click" -async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): +async def test_validate_trigger_rpc_device_not_ready(hass, calls, mock_rpc_device): """Test validate trigger config when RPC device is not ready.""" - assert rpc_wrapper - assert await async_setup_component( hass, automation.DOMAIN, @@ -344,9 +312,11 @@ async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): assert calls[0].data["some"] == "test_trigger_single_push" -async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): +async def test_validate_trigger_invalid_triggers(hass, mock_block_device): """Test for click_event with invalid triggers.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -357,7 +327,7 @@ async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single", CONF_SUBTYPE: "button3", }, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 137149f160899a..a99b28d48e0153 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly diagnostics platform.""" from aiohttp import ClientSession from homeassistant.components.diagnostics import REDACTED @@ -6,6 +6,7 @@ from homeassistant.components.shelly.diagnostics import TO_REDACT from homeassistant.core import HomeAssistant +from . import init_integration from .conftest import MOCK_STATUS_COAP from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,10 +15,10 @@ async def test_block_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSession, coap_wrapper + hass: HomeAssistant, hass_client: ClientSession, mock_block_device ): """Test config entry diagnostics for block device.""" - assert coap_wrapper + await init_integration(hass, 1) entry = hass.config_entries.async_entries(DOMAIN)[0] entry_dict = entry.as_dict() @@ -30,9 +31,9 @@ async def test_block_config_entry_diagnostics( assert result == { "entry": entry_dict, "device_info": { - "name": coap_wrapper.name, - "model": coap_wrapper.model, - "sw_version": coap_wrapper.sw_version, + "name": "Test name", + "model": "SHSW-25", + "sw_version": "some fw string", }, "device_settings": {"coiot": {"update_period": 15}}, "device_status": MOCK_STATUS_COAP, @@ -42,10 +43,10 @@ async def test_block_config_entry_diagnostics( async def test_rpc_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSession, - rpc_wrapper, + mock_rpc_device, ): """Test config entry diagnostics for rpc device.""" - assert rpc_wrapper + await init_integration(hass, 2) entry = hass.config_entries.async_entries(DOMAIN)[0] entry_dict = entry.as_dict() @@ -58,9 +59,9 @@ async def test_rpc_config_entry_diagnostics( assert result == { "entry": entry_dict, "device_info": { - "name": rpc_wrapper.name, - "model": rpc_wrapper.model, - "sw_version": rpc_wrapper.sw_version, + "name": "Test name", + "model": "SHSW-25", + "sw_version": "some fw string", }, "device_settings": {}, "device_status": { diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py new file mode 100644 index 00000000000000..f795b79132f78a --- /dev/null +++ b/tests/components/shelly/test_init.py @@ -0,0 +1,184 @@ +"""Test cases for the Shelly component.""" + +from unittest.mock import AsyncMock + +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +import pytest + +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from . import MOCK_MAC, init_integration + +from tests.common import MockConfigEntry + + +async def test_custom_coap_port(hass, mock_block_device, caplog): + """Test custom coap port.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"coap_port": 7632}}, + ) + await hass.async_block_till_done() + + await init_integration(hass, 1) + assert "Starting CoAP context with UDP port 7632" in caplog.text + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_shared_device_mac( + hass, gen, mock_block_device, mock_rpc_device, device_reg, caplog +): + """Test first time shared device with another domain.""" + config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") + config_entry.add_to_hass(hass) + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + ( + device_registry.CONNECTION_NETWORK_MAC, + device_registry.format_mac(MOCK_MAC), + ) + }, + ) + await init_integration(hass, gen, sleep_period=1000) + assert "will resume when device is online" in caplog.text + + +async def test_setup_entry_not_shelly(hass, caplog): + """Test not Shelly entry.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) is False + await hass.async_block_till_done() + + assert "probably comes from a custom integration" in caplog.text + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_device_connection_error( + hass, gen, mock_block_device, mock_rpc_device, monkeypatch +): + """Test device connection error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_device_auth_error( + hass, gen, mock_block_device, mock_rpc_device, monkeypatch +): + """Test device authentication error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=InvalidAuthError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=InvalidAuthError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + +@pytest.mark.parametrize("entry_sleep, device_sleep", [(None, 0), (1000, 1000)]) +async def test_sleeping_block_device_online( + hass, entry_sleep, device_sleep, mock_block_device, device_reg, caplog +): + """Test sleeping block device online.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") + config_entry.add_to_hass(hass) + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + ( + device_registry.CONNECTION_NETWORK_MAC, + device_registry.format_mac(MOCK_MAC), + ) + }, + ) + + entry = await init_integration(hass, 1, sleep_period=entry_sleep) + assert "will resume when device is online" in caplog.text + + mock_block_device.mock_update() + assert "online, resuming setup" in caplog.text + assert entry.data["sleep_period"] == device_sleep + + +@pytest.mark.parametrize("entry_sleep, device_sleep", [(None, 0), (1000, 1000)]) +async def test_sleeping_rpc_device_online( + hass, entry_sleep, device_sleep, mock_rpc_device, caplog +): + """Test sleeping RPC device online.""" + entry = await init_integration(hass, 2, sleep_period=entry_sleep) + assert "will resume when device is online" in caplog.text + + mock_rpc_device.mock_update() + assert "online, resuming setup" in caplog.text + assert entry.data["sleep_period"] == device_sleep + + +@pytest.mark.parametrize( + "gen, entity_id", + [ + (1, "switch.test_name_channel_1"), + (2, "switch.test_switch_0"), + ], +) +async def test_entry_unload(hass, gen, entity_id, mock_block_device, mock_rpc_device): + """Test entry unload.""" + entry = await init_integration(hass, gen) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id).state is STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert hass.states.get(entity_id).state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "gen, entity_id", + [ + (1, "switch.test_name_channel_1"), + (2, "switch.test_switch_0"), + ], +) +async def test_entry_unload_device_not_ready( + hass, gen, entity_id, mock_block_device, mock_rpc_device +): + """Test entry unload when device is not ready.""" + entry = await init_integration(hass, gen, sleep_period=1000) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id) is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py new file mode 100644 index 00000000000000..b0162f43e1352c --- /dev/null +++ b/tests/components/shelly/test_light.py @@ -0,0 +1,385 @@ +"""Tests for Shelly light platform.""" + +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_EFFECT_LIST, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ColorMode, + LightEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, +) + +from . import init_integration + +RELAY_BLOCK_ID = 0 +LIGHT_BLOCK_ID = 2 + + +async def test_block_device_rgbw_bulb(hass, mock_block_device): + """Test block device RGBW bulb.""" + await init_integration(hass, 1, model="SHBLB-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) + assert attributes[ATTR_BRIGHTNESS] == 48 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGBW, + ] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(attributes[ATTR_EFFECT_LIST]) == 7 + assert attributes[ATTR_EFFECT] == "Off" + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, RGBW = [70, 80, 90, 20], brightness = 33, effect = Flash + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_name_channel_1", + ATTR_RGBW_COLOR: [70, 80, 90, 30], + ATTR_BRIGHTNESS: 33, + ATTR_EFFECT: "Flash", + }, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13, red=70, green=80, blue=90, white=30, effect=3 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) + assert attributes[ATTR_BRIGHTNESS] == 33 + assert attributes[ATTR_EFFECT] == "Flash" + + # Turn on, COLOR_TEMP_KELVIN = 3500 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_COLOR_TEMP_KELVIN: 3500}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", temp=3500, mode="white" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + + +async def test_block_device_rgb_bulb(hass, mock_block_device, monkeypatch, caplog): + """Test block device RGB bulb.""" + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") + await init_integration(hass, 1, model="SHCB-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert attributes[ATTR_BRIGHTNESS] == 48 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGB, + ] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(attributes[ATTR_EFFECT_LIST]) == 4 + assert attributes[ATTR_EFFECT] == "Off" + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, RGB = [70, 80, 90], brightness = 33, effect = Flash + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_name_channel_1", + ATTR_RGB_COLOR: [70, 80, 90], + ATTR_BRIGHTNESS: 33, + ATTR_EFFECT: "Flash", + }, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13, red=70, green=80, blue=90, effect=3 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert attributes[ATTR_BRIGHTNESS] == 33 + assert attributes[ATTR_EFFECT] == "Flash" + + # Turn on, COLOR_TEMP_KELVIN = 3500 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_COLOR_TEMP_KELVIN: 3500}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", temp=3500, mode="white" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + + # Turn on with unsupported effect + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_EFFECT: "Breath"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", mode="color" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_EFFECT] == "Off" + assert "Effect 'Breath' not supported" in caplog.text + + +async def test_block_device_white_bulb(hass, mock_block_device, monkeypatch, caplog): + """Test block device white bulb.""" + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "colorTemp") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect") + await init_integration(hass, 1, model="SHVIN-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, brightness = 33 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_BRIGHTNESS: 33}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_BRIGHTNESS] == 33 + + +@pytest.mark.parametrize( + "model", + [ + "SHBDUO-1", + "SHCB-1", + "SHDM-1", + "SHDM-2", + "SHRGBW2", + "SHVIN-1", + ], +) +async def test_block_device_support_transition( + hass, mock_block_device, model, monkeypatch +): + """Test block device supports transition.""" + monkeypatch.setitem( + mock_block_device.settings, "fw", "20220809-122808/v1.12-g99f7e0b" + ) + await init_integration(hass, 1, model=model) + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION + + # Turn on, TRANSITION = 4 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_TRANSITION: 4}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", transition=4000 + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_ON + + # Turn off, TRANSITION = 6, limit to 5000ms + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_TRANSITION: 6}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off", transition=5000 + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + +async def test_block_device_relay_app_type_light(hass, mock_block_device, monkeypatch): + """Test block device relay in app type set to light mode.""" + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + monkeypatch.setitem( + mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" + ) + await init_integration(hass, 1) + assert hass.states.get("switch.test_name_channel_1") is None + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Turn off + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( + turn="on" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_ON + + +async def test_block_device_no_light_blocks(hass, mock_block_device, monkeypatch): + """Test block device without light blocks.""" + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "roller") + await init_integration(hass, 1) + assert hass.states.get("light.test_name_channel_1") is None + + +async def test_rpc_device_switch_type_lights_mode(hass, mock_rpc_device, monkeypatch): + """Test RPC device with switch in consumption type lights mode.""" + monkeypatch.setitem( + mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] + ) + await init_integration(hass, 2) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_switch_0"}, + blocking=True, + ) + assert hass.states.get("light.test_switch_0").state == STATE_ON + + monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_switch_0"}, + blocking=True, + ) + mock_rpc_device.mock_update() + assert hass.states.get("light.test_switch_0").state == STATE_OFF diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 5e267dcfd8f4b5..b176b37c7e9148 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -7,14 +7,23 @@ EVENT_SHELLY_CLICK, ) from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component +from . import init_integration + from tests.components.logbook.common import MockRow, mock_humanify -async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): +async def test_humanify_shelly_click_event_block_device(hass, mock_block_device): """Test humanifying Shelly click event for block device.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -24,7 +33,7 @@ async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): MockRow( EVENT_SHELLY_CLICK, { - ATTR_DEVICE_ID: coap_wrapper.device_id, + ATTR_DEVICE_ID: device.id, ATTR_DEVICE: "shellyix3-12345678", ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1, @@ -57,9 +66,12 @@ async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): ) -async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): +async def test_humanify_shelly_click_event_rpc_device(hass, mock_rpc_device): """Test humanifying Shelly click event for rpc device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -69,7 +81,7 @@ async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): MockRow( EVENT_SHELLY_CLICK, { - ATTR_DEVICE_ID: rpc_wrapper.device_id, + ATTR_DEVICE_ID: device.id, ATTR_DEVICE: "shellyplus1pm-12345678", ATTR_CLICK_TYPE: "single_push", ATTR_CHANNEL: 1, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index cb93d9dace5260..458de9c655b3c7 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly switch platform.""" from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -7,19 +7,15 @@ STATE_OFF, STATE_ON, ) -from homeassistant.helpers.entity_component import async_update_entity + +from . import init_integration RELAY_BLOCK_ID = 0 -async def test_block_device_services(hass, coap_wrapper): +async def test_block_device_services(hass, mock_block_device): """Test block device turn on/off services.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 1) await hass.services.async_call( SWITCH_DOMAIN, @@ -38,72 +34,43 @@ async def test_block_device_services(hass, coap_wrapper): assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF -async def test_block_device_update(hass, coap_wrapper, monkeypatch): +async def test_block_device_update(hass, mock_block_device, monkeypatch): """Test block device update.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() - - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "output", False) - await async_update_entity(hass, "switch.test_name_channel_1") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", False) + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "output", True) - await async_update_entity(hass, "switch.test_name_channel_1") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", True) + mock_block_device.mock_update() assert hass.states.get("switch.test_name_channel_1").state == STATE_ON -async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch): +async def test_block_device_no_relay_blocks(hass, mock_block_device, monkeypatch): """Test block device without relay blocks.""" - assert coap_wrapper - - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "type", "roller") + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch): +async def test_block_device_mode_roller(hass, mock_block_device, monkeypatch): """Test block device in roller mode.""" - assert coap_wrapper - - monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setitem(mock_block_device.settings, "mode", "roller") + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch): +async def test_block_device_app_type_light(hass, mock_block_device, monkeypatch): """Test block device in app type set to light mode.""" - assert coap_wrapper - monkeypatch.setitem( - coap_wrapper.device.settings["relays"][0], "appliance_type", "light" - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) + mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await hass.async_block_till_done() + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): """Test RPC device turn on/off services.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) await hass.services.async_call( SWITCH_DOMAIN, @@ -113,28 +80,21 @@ async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): ) assert hass.states.get("switch.test_switch_0").state == STATE_ON - monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False) + monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test_switch_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("switch.test_switch_0").state == STATE_OFF -async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_switch_type_lights_mode(hass, mock_rpc_device, monkeypatch): """Test RPC device with switch in consumption type lights mode.""" - assert rpc_wrapper - monkeypatch.setitem( - rpc_wrapper.device.config["sys"]["ui_data"], - "consumption_types", - ["lights"], - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await hass.async_block_till_done() + await init_integration(hass, 2) assert hass.states.get("switch.test_switch_0") is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 4d863c59390cc1..4da81e076aef3a 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -1,83 +1,277 @@ """Tests for Shelly update platform.""" -from homeassistant.components.shelly.const import DOMAIN -from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNKNOWN +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +import pytest + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.shelly.const import DOMAIN, REST_SENSORS_UPDATE_INTERVAL +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt +from . import MOCK_MAC, init_integration -async def test_block_update(hass: HomeAssistant, coap_wrapper, monkeypatch): - """Test block device update entity.""" - assert coap_wrapper +from tests.common import async_fire_time_changed + +@pytest.mark.parametrize( + "gen, domain, unique_id, object_id", + [ + (1, BINARY_SENSOR_DOMAIN, f"{MOCK_MAC}-fwupdate", "firmware_update"), + (1, BUTTON_DOMAIN, "test_name_ota_update", "ota_update"), + (1, BUTTON_DOMAIN, "test_name_ota_update_beta", "ota_update_beta"), + (2, BINARY_SENSOR_DOMAIN, f"{MOCK_MAC}-sys-fwupdate", "firmware_update"), + (2, BUTTON_DOMAIN, "test_name_ota_update", "ota_update"), + (2, BUTTON_DOMAIN, "test_name_ota_update_beta", "ota_update_beta"), + ], +) +async def test_remove_legacy_entities( + hass: HomeAssistant, + gen, + domain, + unique_id, + object_id, + mock_block_device, + mock_rpc_device, +): + """Test removes legacy update entities.""" + entity_id = f"{domain}.test_name_{object_id}" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + unique_id, + suggested_object_id=f"test_name_{object_id}", + disabled_by=None, + ) + + assert entity_registry.async_get(entity_id) is not None + + await init_integration(hass, gen) + + assert entity_registry.async_get(entity_id) is None + + +async def test_block_update(hass: HomeAssistant, mock_block_device, monkeypatch): + """Test block device update entity.""" entity_registry = async_get(hass) entity_registry.async_get_or_create( UPDATE_DOMAIN, DOMAIN, - "test-mac-fwupdate", + f"{MOCK_MAC}-fwupdate", suggested_object_id="test_name_firmware_update", disabled_by=None, ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, UPDATE_DOMAIN) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + await init_integration(hass, 1) + + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, ) - await hass.async_block_till_done() + assert mock_block_device.trigger_ota_update.call_count == 1 + + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_block_beta_update(hass: HomeAssistant, mock_block_device, monkeypatch): + """Test block device beta update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-fwupdate_beta", + suggested_object_id="test_name_beta_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "") + await init_integration(hass, 1) + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "1" + assert state.attributes[ATTR_IN_PROGRESS] is False + + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() - assert state + state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() - assert coap_wrapper.device.trigger_ota_update.call_count == 1 + assert mock_block_device.trigger_ota_update.call_count == 1 - monkeypatch.setitem(coap_wrapper.device.status["update"], "old_version", None) - monkeypatch.setitem(coap_wrapper.device.status["update"], "new_version", None) + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() - state = hass.states.get("update.test_name_firmware_update") - assert state - assert state.state == STATE_UNKNOWN + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False -async def test_rpc_update(hass: HomeAssistant, rpc_wrapper, monkeypatch): - """Test rpc device update entity.""" - assert rpc_wrapper +async def test_block_update_connection_error( + hass: HomeAssistant, mock_block_device, monkeypatch, caplog +): + """Test block device update connection error.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setattr( + mock_block_device, + "trigger_ota_update", + AsyncMock(side_effect=DeviceConnectionError), + ) + await init_integration(hass, 1) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + assert "Error starting OTA update" in caplog.text + +async def test_block_update_auth_error( + hass: HomeAssistant, mock_block_device, monkeypatch +): + """Test block device update authentication error.""" entity_registry = async_get(hass) entity_registry.async_get_or_create( UPDATE_DOMAIN, DOMAIN, - "12345678-sys-fwupdate", + f"{MOCK_MAC}-fwupdate", suggested_object_id="test_name_firmware_update", disabled_by=None, ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setattr( + mock_block_device, + "trigger_ota_update", + AsyncMock(side_effect=InvalidAuthError), + ) + entry = await init_integration(hass, 1) + + assert entry.state == ConfigEntryState.LOADED - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, UPDATE_DOMAIN) + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, ) - await hass.async_block_till_done() - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") - await hass.async_block_till_done() - state = hass.states.get("update.test_name_firmware_update") + assert entry.state == ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch): + """Test RPC device update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + }, + ) + await init_integration(hass, 2) - assert state + state = hass.states.get("update.test_name_firmware_update") assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False await hass.services.async_call( UPDATE_DOMAIN, @@ -85,16 +279,189 @@ async def test_rpc_update(hass: HomeAssistant, rpc_wrapper, monkeypatch): {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() - assert rpc_wrapper.device.trigger_ota_update.call_count == 1 + assert mock_rpc_device.trigger_ota_update.call_count == 1 - monkeypatch.setitem(rpc_wrapper.device.status["sys"], "available_updates", {}) - rpc_wrapper.device.shelly = None + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_rpc_beta_update(hass: HomeAssistant, mock_rpc_device, monkeypatch): + """Test RPC device beta update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate_beta", + suggested_object_id="test_name_beta_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + await init_integration(hass, 2) + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "1" + assert state.attributes[ATTR_IN_PROGRESS] is False + + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": "2b"}, + }, + ) + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, + blocking=True, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is True + + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize( + "exc, error", + [ + (DeviceConnectionError, "Error starting OTA update"), + (RpcCallError(-1, "error"), "OTA update request error"), + ], +) +async def test_rpc_update__errors( + hass: HomeAssistant, exc, error, mock_rpc_device, monkeypatch, caplog +): + """Test RPC device update connection/call errors.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + monkeypatch.setattr( + mock_rpc_device, "trigger_ota_update", AsyncMock(side_effect=exc) + ) + await init_integration(hass, 2) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + assert error in caplog.text + + +async def test_rpc_update_auth_error( + hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog +): + """Test RPC device update authentication error.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + monkeypatch.setattr( + mock_rpc_device, + "trigger_ota_update", + AsyncMock(side_effect=InvalidAuthError), + ) + entry = await init_integration(hass, 2) + + assert entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + + assert entry.state == ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN - assert state - assert state.state == STATE_UNKNOWN + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/simplepush/test_config_flow.py b/tests/components/simplepush/test_config_flow.py index 6f0d6a73aa4f56..02db81ceaa77e3 100644 --- a/tests/components/simplepush/test_config_flow.py +++ b/tests/components/simplepush/test_config_flow.py @@ -29,9 +29,7 @@ def simplepush_setup_fixture(): @pytest.fixture(autouse=True) def mock_api_request(): """Patch simplepush api request.""" - with patch("homeassistant.components.simplepush.config_flow.send"), patch( - "homeassistant.components.simplepush.config_flow.send_encrypted" - ): + with patch("homeassistant.components.simplepush.config_flow.send"): yield diff --git a/tests/components/simplisafe/conftest.py b/tests/components/simplisafe/conftest.py index 54ab7fbe9d788a..165f71cde04b29 100644 --- a/tests/components/simplisafe/conftest.py +++ b/tests/components/simplisafe/conftest.py @@ -58,25 +58,25 @@ def credentials_config_fixture(): } -@pytest.fixture(name="data_latest_event", scope="session") +@pytest.fixture(name="data_latest_event", scope="package") def data_latest_event_fixture(): """Define latest event data.""" return json.loads(load_fixture("latest_event_data.json", "simplisafe")) -@pytest.fixture(name="data_sensor", scope="session") +@pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" return json.loads(load_fixture("sensor_data.json", "simplisafe")) -@pytest.fixture(name="data_settings", scope="session") +@pytest.fixture(name="data_settings", scope="package") def data_settings_fixture(): """Define settings data.""" return json.loads(load_fixture("settings_data.json", "simplisafe")) -@pytest.fixture(name="data_subscription", scope="session") +@pytest.fixture(name="data_subscription", scope="package") def data_subscription_fixture(): """Define subscription data.""" return json.loads(load_fixture("subscription_data.json", "simplisafe")) diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 446d9d5e9e3747..f7a88fe0d0617b 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -8,9 +8,17 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_simplisa """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "options": { - "code": REDACTED, - }, + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "simplisafe", + "title": REDACTED, + "data": {"token": REDACTED, "username": REDACTED}, + "options": {"code": REDACTED}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "subscription_data": { "system_123": { diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index aaf1679478a0a3..c93a1a8c8e10c2 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test siren registered attributes to be excluded.""" await async_setup_component( hass, siren.DOMAIN, {siren.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index b953d8692a8e51..2ce5db5e0ca7ff 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -1,7 +1,7 @@ """Fixtures for sma tests.""" from unittest.mock import patch -from pysma.const import DEVCLASS_INVERTER +from pysma.const import GENERIC_SENSORS from pysma.definitions import sensor_map from pysma.sensor import Sensors import pytest @@ -32,7 +32,7 @@ async def init_integration(hass, mock_config_entry): mock_config_entry.add_to_hass(hass) with patch("pysma.SMA.read"), patch( - "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[DEVCLASS_INVERTER]) + "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[GENERIC_SENSORS]) ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py new file mode 100644 index 00000000000000..d5802642c37338 --- /dev/null +++ b/tests/components/snooz/__init__.py @@ -0,0 +1,105 @@ +"""Tests for the Snooz component.""" +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import patch + +from bleak import BLEDevice +from pysnooz.commands import SnoozCommandData +from pysnooz.testing import MockSnoozDevice + +from homeassistant.components.snooz.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TEST_ADDRESS = "00:00:00:00:AB:CD" +TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" +TEST_SNOOZ_DISPLAY_NAME = "Snooz ABCD" +TEST_PAIRING_TOKEN = "deadbeef" + +NOT_SNOOZ_SERVICE_INFO = BluetoothServiceInfo( + name="Definitely not snooz", + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +SNOOZ_SERVICE_INFO_PAIRING = BluetoothServiceInfo( + name=TEST_SNOOZ_LOCAL_NAME, + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={65552: bytes([4]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, + service_uuids=[ + "80c37f00-cc16-11e4-8830-0800200c9a66", + "90759319-1668-44da-9ef3-492d593bd1e5", + ], + service_data={}, + source="local", +) + +SNOOZ_SERVICE_INFO_NOT_PAIRING = BluetoothServiceInfo( + name=TEST_SNOOZ_LOCAL_NAME, + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={65552: bytes([4]) + bytes([0] * 8)}, + service_uuids=[ + "80c37f00-cc16-11e4-8830-0800200c9a66", + "90759319-1668-44da-9ef3-492d593bd1e5", + ], + service_data={}, + source="local", +) + + +@dataclass +class SnoozFixture: + """Snooz test fixture.""" + + entry: MockConfigEntry + device: MockSnoozDevice + + +async def create_mock_snooz( + connected: bool = True, + initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0), +) -> MockSnoozDevice: + """Create a mock device.""" + + ble_device = SNOOZ_SERVICE_INFO_NOT_PAIRING + device = MockSnoozDevice(ble_device, initial_state=initial_state) + + # execute a command to initiate the connection + if connected is True: + await device.async_execute_command(initial_state) + + return device + + +async def create_mock_snooz_config_entry( + hass: HomeAssistant, device: MockSnoozDevice +) -> MockConfigEntry: + """Create a mock config entry.""" + + with patch( + "homeassistant.components.snooz.SnoozDevice", return_value=device + ), patch( + "homeassistant.components.snooz.async_ble_device_from_address", + return_value=BLEDevice(device.address, device.name), + ): + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snooz/conftest.py b/tests/components/snooz/conftest.py new file mode 100644 index 00000000000000..f99dcfeba727b7 --- /dev/null +++ b/tests/components/snooz/conftest.py @@ -0,0 +1,23 @@ +"""Snooz test fixtures and configuration.""" +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant + +from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture() +async def mock_connected_snooz(hass: HomeAssistant): + """Mock a Snooz configuration entry and device.""" + + device = await create_mock_snooz() + entry = await create_mock_snooz_config_entry(hass, device) + + yield SnoozFixture(entry, device) diff --git a/tests/components/snooz/test_config.py b/tests/components/snooz/test_config.py new file mode 100644 index 00000000000000..e8848aa48e0b10 --- /dev/null +++ b/tests/components/snooz/test_config.py @@ -0,0 +1,26 @@ +"""Test Snooz configuration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from . import SnoozFixture + + +async def test_removing_entry_cleans_up_connections( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +): + """Tests setup and removal of a config entry, ensuring connections are cleaned up.""" + await hass.config_entries.async_remove(mock_connected_snooz.entry.entry_id) + await hass.async_block_till_done() + + assert not mock_connected_snooz.device.is_connected + + +async def test_reloading_entry_cleans_up_connections( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +): + """Test reloading an entry disconnects any existing connections.""" + await hass.config_entries.async_reload(mock_connected_snooz.entry.entry_id) + await hass.async_block_till_done() + + assert not mock_connected_snooz.device.is_connected diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py new file mode 100644 index 00000000000000..65076bf2e038a7 --- /dev/null +++ b/tests/components/snooz/test_config_flow.py @@ -0,0 +1,325 @@ +"""Test the Snooz config flow.""" +from __future__ import annotations + +import asyncio +from asyncio import Event +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.snooz import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + NOT_SNOOZ_SERVICE_INFO, + SNOOZ_SERVICE_INFO_NOT_PAIRING, + SNOOZ_SERVICE_INFO_PAIRING, + TEST_ADDRESS, + TEST_PAIRING_TOKEN, + TEST_SNOOZ_DISPLAY_NAME, +) + +from tests.common import MockConfigEntry + + +async def test_async_step_bluetooth_valid_device(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + await _test_setup_entry(hass, result["flow_id"]) + + +async def test_async_step_bluetooth_waits_to_pair(hass: HomeAssistant): + """Test discovery via bluetooth with a device that's not in pairing mode, but enters pairing mode to complete setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_NOT_PAIRING, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + await _test_pairs(hass, result["flow_id"]) + + +async def test_async_step_bluetooth_retries_pairing(hass: HomeAssistant): + """Test discovery via bluetooth with a device that's not in pairing mode, times out waiting, but eventually complete setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_NOT_PAIRING, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + retry_id = await _test_pairs_timeout(hass, result["flow_id"]) + await _test_pairs(hass, retry_id) + + +async def test_async_step_bluetooth_not_snooz(hass: HomeAssistant): + """Test discovery via bluetooth not Snooz.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_SNOOZ_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant): + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass: HomeAssistant): + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["data_schema"] + # ensure discovered devices are listed as options + assert result["data_schema"].schema["name"].container == [TEST_SNOOZ_DISPLAY_NAME] + await _test_setup_entry( + hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + ) + + +async def test_async_step_user_with_found_devices_waits_to_pair(hass: HomeAssistant): + """Test setup from service info cache with devices found that require pairing mode.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + await _test_pairs(hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}) + + +async def test_async_step_user_with_found_devices_retries_pairing(hass: HomeAssistant): + """Test setup from service info cache with devices found that require pairing mode, times out, then completes.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + + retry_id = await _test_pairs_timeout(hass, result["flow_id"], user_input) + await _test_pairs(hass, retry_id, user_input) + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant): + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.snooz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup(hass: HomeAssistant): + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant): + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant): + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_async_step_user_takes_precedence_over_discovery(hass: HomeAssistant): + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + await _test_setup_entry( + hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + ) + + # Verify the original one was aborted + assert not hass.config_entries.flow.async_progress() + + +async def _test_pairs( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> None: + pairing_mode_entered = Event() + + async def _async_process_advertisements( + _hass, _callback, _matcher, _mode, _timeout + ): + await pairing_mode_entered.wait() + service_info = SNOOZ_SERVICE_INFO_PAIRING + assert _callback(service_info) + return service_info + + with patch( + "homeassistant.components.snooz.config_flow.async_process_advertisements", + _async_process_advertisements, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input or {}, + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "wait_for_pairing_mode" + + pairing_mode_entered.set() + await hass.async_block_till_done() + + await _test_setup_entry(hass, result["flow_id"], user_input) + + +async def _test_pairs_timeout( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> str: + with patch( + "homeassistant.components.snooz.config_flow.async_process_advertisements", + side_effect=asyncio.TimeoutError(), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, user_input=user_input or {} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "wait_for_pairing_mode" + await hass.async_block_till_done() + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "pairing_timeout" + + return result2["flow_id"] + + +async def _test_setup_entry( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> None: + with patch("homeassistant.components.snooz.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input or {}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ADDRESS: TEST_ADDRESS, + CONF_TOKEN: TEST_PAIRING_TOKEN, + } + assert result["result"].unique_id == TEST_ADDRESS diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py new file mode 100644 index 00000000000000..30528336e2d2ae --- /dev/null +++ b/tests/components/snooz/test_fan.py @@ -0,0 +1,264 @@ +"""Test Snooz fan entity.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import Mock + +from pysnooz.api import SnoozDeviceState, UnknownSnoozState +from pysnooz.commands import SnoozCommandResult, SnoozCommandResultStatus +from pysnooz.testing import MockSnoozDevice +import pytest + +from homeassistant.components import fan +from homeassistant.components.snooz.const import DOMAIN +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry + +from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry + + +async def test_turn_on(hass: HomeAssistant, snooz_fan_entity_id: str): + """Test turning on the device.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100]) +async def test_turn_on_with_percentage( + hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int +): + """Test turning on the device with a percentage.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == percentage + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100]) +async def test_set_percentage( + hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int +): + """Test setting the fan percentage.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.attributes[fan.ATTR_PERCENTAGE] == percentage + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_set_0_percentage_turns_off( + hass: HomeAssistant, snooz_fan_entity_id: str +): + """Test turning off the device by setting the percentage/volume to 0.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 66}, + blocking=True, + ) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_OFF + # doesn't overwrite percentage when turning off + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_turn_off(hass: HomeAssistant, snooz_fan_entity_id: str): + """Test turning off the device.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_OFF + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_push_events( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str +): + """Test state update events from snooz device.""" + mock_connected_snooz.device.trigger_state(SnoozDeviceState(False, 64)) + + state = hass.states.get(snooz_fan_entity_id) + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 64 + + mock_connected_snooz.device.trigger_state(SnoozDeviceState(True, 12)) + + state = hass.states.get(snooz_fan_entity_id) + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 12 + + mock_connected_snooz.device.trigger_disconnect() + + state = hass.states.get(snooz_fan_entity_id) + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_restore_state(hass: HomeAssistant): + """Tests restoring entity state.""" + device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) + + entry = await create_mock_snooz_config_entry(hass, device) + entity_id = get_fan_entity_id(hass, device) + + # call service to store state + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id], fan.ATTR_PERCENTAGE: 33}, + blocking=True, + ) + + # unload entry + await hass.config_entries.async_unload(entry.entry_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # reload entry + await create_mock_snooz_config_entry(hass, device) + + # should match last known state + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_restore_unknown_state(hass: HomeAssistant): + """Tests restoring entity state that was unknown.""" + device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) + + entry = await create_mock_snooz_config_entry(hass, device) + entity_id = get_fan_entity_id(hass, device) + + # unload entry + await hass.config_entries.async_unload(entry.entry_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # reload entry + await create_mock_snooz_config_entry(hass, device) + + # should match last known state + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + +async def test_command_results( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str +): + """Test device command results.""" + mock_execute = Mock(spec=mock_connected_snooz.device.async_execute_command) + + mock_connected_snooz.device.async_execute_command = mock_execute + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.SUCCESSFUL, timedelta() + ) + mock_connected_snooz.device.state = SnoozDeviceState(on=True, volume=56) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 56 + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.CANCELLED, timedelta() + ) + mock_connected_snooz.device.state = SnoozDeviceState(on=False, volume=15) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + # the device state shouldn't be written when cancelled + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 56 + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.UNEXPECTED_ERROR, timedelta() + ) + + with pytest.raises(HomeAssistantError) as failure: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + assert failure.match("failed with status") + + +@pytest.fixture(name="snooz_fan_entity_id") +async def fixture_snooz_fan_entity_id( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +) -> str: + """Mock a Snooz fan entity and config entry.""" + + yield get_fan_entity_id(hass, mock_connected_snooz.device) + + +def get_fan_entity_id(hass: HomeAssistant, device: MockSnoozDevice) -> str: + """Get the entity ID for a mock device.""" + + return entity_registry.async_get(hass).async_get_entity_id( + Platform.FAN, DOMAIN, device.address + ) diff --git a/tests/components/sonarr/fixtures/system-status.json b/tests/components/sonarr/fixtures/system-status.json index fe6198a0444554..311cadd4ff08db 100644 --- a/tests/components/sonarr/fixtures/system-status.json +++ b/tests/components/sonarr/fixtures/system-status.json @@ -1,5 +1,6 @@ { "appName": "Sonarr", + "instanceName": "Sonarr", "version": "3.0.6.1451", "buildTime": "2022-01-23T16:51:56Z", "isDebug": false, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index f776fb62d58d13..2ac1cb460cb80f 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -135,6 +135,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index b4186ecacef4d7..4b8071ad1eaada 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,11 +1,8 @@ """Tests for SpeedTest integration.""" -from collections.abc import Awaitable from datetime import timedelta -from typing import Callable from unittest.mock import MagicMock -from aiohttp import ClientWebSocketResponse import speedtest from homeassistant.components.speedtestdotnet.const import ( @@ -13,7 +10,6 @@ CONF_SERVER_ID, CONF_SERVER_NAME, DOMAIN, - SPEED_TEST_SERVICE, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE @@ -21,7 +17,6 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.repairs import get_repairs async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -43,7 +38,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED assert hass.data[DOMAIN] - assert hass.services.has_service(DOMAIN, SPEED_TEST_SERVICE) async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: @@ -125,28 +119,3 @@ async def test_get_best_server_error(hass: HomeAssistant, mock_api: MagicMock) - state = hass.states.get("sensor.speedtest_ping") assert state is not None assert state.state == STATE_UNAVAILABLE - - -async def test_deprecated_service_alert( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], -) -> None: - """Test that an issue is raised if deprecated services is called.""" - entry = MockConfigEntry( - domain=DOMAIN, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, - "speedtest", - {}, - blocking=True, - ) - await hass.async_block_till_done() - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_service" diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index ea54745048e3c5..e5bbc163249b58 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, recorder_mock) -> None: +async def test_form(recorder_mock, hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -53,7 +53,7 @@ async def test_form(hass: HomeAssistant, recorder_mock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant, recorder_mock) -> None: +async def test_import_flow_success(recorder_mock, hass: HomeAssistant) -> None: """Test a successful import of yaml.""" with patch( @@ -80,7 +80,7 @@ async def test_import_flow_success(hass: HomeAssistant, recorder_mock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_already_exist(hass: HomeAssistant, recorder_mock) -> None: +async def test_import_flow_already_exist(recorder_mock, hass: HomeAssistant) -> None: """Test import of yaml already exist.""" MockConfigEntry( @@ -103,7 +103,7 @@ async def test_import_flow_already_exist(hass: HomeAssistant, recorder_mock) -> assert result3["reason"] == "already_configured" -async def test_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: +async def test_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" result4 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -124,7 +124,7 @@ async def test_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: assert result4["errors"] == {"db_url": "db_url_invalid"} -async def test_flow_fails_invalid_query(hass: HomeAssistant, recorder_mock) -> None: +async def test_flow_fails_invalid_query(recorder_mock, hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" result4 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -170,7 +170,7 @@ async def test_flow_fails_invalid_query(hass: HomeAssistant, recorder_mock) -> N } -async def test_options_flow(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow(recorder_mock, hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,7 +219,7 @@ async def test_options_flow(hass: HomeAssistant, recorder_mock) -> None: async def test_options_flow_name_previously_removed( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test options config flow where the name was missing.""" entry = MockConfigEntry( @@ -270,7 +270,7 @@ async def test_options_flow_name_previously_removed( } -async def test_options_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None: """Test options flow fails incorrect db url.""" entry = MockConfigEntry( domain=DOMAIN, @@ -313,7 +313,7 @@ async def test_options_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> async def test_options_flow_fails_invalid_query( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test options flow fails incorrect query and template.""" entry = MockConfigEntry( @@ -369,7 +369,7 @@ async def test_options_flow_fails_invalid_query( } -async def test_options_flow_db_url_empty(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow_db_url_empty(recorder_mock, hass: HomeAssistant) -> None: """Test options config flow with leaving db_url empty.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index cb19ae8720a3eb..3cf2837353e5fb 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -11,7 +11,7 @@ SRP_ENERGY_DOMAIN, ) from homeassistant.components.srp_energy.sensor import SrpEntity, async_setup_entry -from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR +from homeassistant.const import ENERGY_KILO_WATT_HOUR async def test_async_setup_entry(hass): @@ -93,7 +93,7 @@ async def test_srp_entity(hass): assert srp_entity.icon == ICON assert srp_entity.usage == "2.00" assert srp_entity.should_poll is False - assert srp_entity.extra_state_attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert srp_entity.attribution == ATTRIBUTION assert srp_entity.available is not None assert srp_entity.device_class is SensorDeviceClass.ENERGY assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index 0b390ae469b3f1..7b6d67895e57ec 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -1,6 +1,7 @@ """Configuration for SSDP tests.""" from unittest.mock import AsyncMock, patch +from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp_listener import SsdpListener import pytest @@ -16,6 +17,15 @@ async def silent_ssdp_listener(): yield SsdpListener +@pytest.fixture(autouse=True) +async def disabled_upnp_server(): + """Disable UPnpServer.""" + with patch("homeassistant.components.ssdp.UpnpServer.async_start"), patch( + "homeassistant.components.ssdp.UpnpServer.async_stop" + ), patch("homeassistant.components.ssdp._async_find_next_available_port"): + yield UpnpServer + + @pytest.fixture def mock_flow_init(hass): """Mock hass.config_entries.flow.async_init.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index bf88f45acf90ca..4076ef4685d20b 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -5,6 +5,7 @@ from ipaddress import IPv4Address from unittest.mock import ANY, AsyncMock, patch +from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp import udn_from_headers from async_upnp_client.ssdp_listener import SsdpListener from async_upnp_client.utils import CaseInsensitiveDict @@ -34,7 +35,7 @@ async def init_ssdp_component(hass: homeassistant) -> SsdpListener: """Initialize ssdp component and get SsdpListener.""" await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) await hass.async_block_till_done() - return hass.data[ssdp.DOMAIN]._ssdp_listeners[0] + return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] @patch( @@ -407,7 +408,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st( @patch( - "homeassistant.components.ssdp.Scanner._async_build_source_set", + "homeassistant.components.ssdp.async_build_source_set", return_value={IPv4Address("192.168.1.1")}, ) @pytest.mark.usefixtures("mock_get_source_ip") @@ -668,9 +669,9 @@ async def test_async_detect_interfaces_setting_empty_route( """Test without default interface config and the route returns nothing.""" await init_ssdp_component(hass) - ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + ssdp_listeners = hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners} - assert sources == {("2001:db8::%1", 0, 0, 1), ("192.168.1.5", 0)} + assert sources == {("2001:db8::", 0, 0, 1), ("192.168.1.5", 0)} @pytest.mark.usefixtures("mock_get_source_ip") @@ -694,18 +695,29 @@ async def test_bind_failure_skips_adapter( """Test that an adapter with a bind failure is skipped.""" async def _async_start(self): - if self.source == ("2001:db8::%1", 0, 0, 1): + if self.source == ("2001:db8::", 0, 0, 1): raise OSError SsdpListener.async_start = _async_start + UpnpServer.async_start = _async_start await init_ssdp_component(hass) assert "Failed to setup listener for" in caplog.text - ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + ssdp_listeners: list[SsdpListener] = hass.data[ssdp.DOMAIN][ + ssdp.SSDP_SCANNER + ]._ssdp_listeners sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners} assert sources == {("192.168.1.5", 0)} # Note no SsdpListener for IPv6 address. + assert "Failed to setup server for" in caplog.text + + upnp_servers: list[UpnpServer] = hass.data[ssdp.DOMAIN][ + ssdp.UPNP_SERVER + ]._upnp_servers + sources = {upnp_server.source for upnp_server in upnp_servers} + assert sources == {("192.168.1.5", 0)} # Note no UpnpServer for IPv6 address. + @pytest.mark.usefixtures("mock_get_source_ip") @patch( diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 56255216f528c2..691159fe2fcf72 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -769,6 +769,56 @@ def mock_now(): "value_9": float(round(statistics.stdev(VALUES_NUMERIC), 2)), "unit": "°C", }, + { + "source_sensor_domain": "sensor", + "name": "sum", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(sum(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "sum_differences", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + sum( + [ + abs(20 - 17), + abs(15.2 - 20), + abs(5 - 15.2), + abs(3.8 - 5), + abs(9.2 - 3.8), + abs(6.7 - 9.2), + abs(14 - 6.7), + abs(6 - 14), + ] + ) + ), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "sum_differences_nonnegative", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + sum( + [ + 20 - 17, + 15.2 - 0, + 5 - 0, + 3.8 - 0, + 9.2 - 3.8, + 6.7 - 0, + 14 - 6.7, + 6 - 0, + ] + ) + ), + "unit": "°C", + }, { "source_sensor_domain": "sensor", "name": "total", @@ -1010,7 +1060,7 @@ async def test_invalid_state_characteristic(hass: HomeAssistant): assert state is None -async def test_initialize_from_database(hass: HomeAssistant, recorder_mock): +async def test_initialize_from_database(recorder_mock, hass: HomeAssistant): """Test initializing the statistics from the recorder database.""" # enable and pre-fill the recorder await hass.async_block_till_done() @@ -1049,7 +1099,7 @@ async def test_initialize_from_database(hass: HomeAssistant, recorder_mock): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS -async def test_initialize_from_database_with_maxage(hass: HomeAssistant, recorder_mock): +async def test_initialize_from_database_with_maxage(recorder_mock, hass: HomeAssistant): """Test initializing the statistics from the database.""" now = dt_util.utcnow() mock_data = { @@ -1109,7 +1159,7 @@ def mock_purge(self, *args): ) + timedelta(hours=1) -async def test_reload(hass: HomeAssistant, recorder_mock): +async def test_reload(recorder_mock, hass: HomeAssistant): """Verify we can reload statistics sensors.""" await async_setup_component( diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 54400af65ab519..e77b062fa9c820 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -794,7 +794,7 @@ async def test_durations(hass, worker_finished_stream): assert math.isclose( (av_part.duration - av_part.start_time) / av.time_base, part.duration, - abs_tol=2 / av_part.streams.video[0].rate + 1e-6, + abs_tol=2 / av_part.streams.video[0].average_rate + 1e-6, ) # Also check that the sum of the durations so far matches the last dts # in the media. diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index b6a79ab8829500..bd107f4bb3776c 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -1,5 +1,7 @@ """Sample API response data for tests.""" +from datetime import datetime, timezone + from homeassistant.components.subaru.const import ( API_GEN_1, API_GEN_2, @@ -46,10 +48,12 @@ }, } +MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) + VEHICLE_STATUS_EV = { "status": { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -63,21 +67,17 @@ "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": 17, + "EV_DISTANCE_TO_EMPTY": 1, "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "65535", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", - "EXT_EXTERNAL_TEMP": "21.5", + "EV_STATE_OF_CHARGE_PERCENT": 20, + "EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME, + "EXT_EXTERNAL_TEMP": 21.5, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -100,7 +100,7 @@ "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 2550, + "TYRE_PRESSURE_FRONT_LEFT": 0, "TYRE_PRESSURE_FRONT_RIGHT": 2550, "TYRE_PRESSURE_REAR_LEFT": 2450, "TYRE_PRESSURE_REAR_RIGHT": 2350, @@ -121,10 +121,11 @@ } } + VEHICLE_STATUS_G2 = { "status": { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -138,9 +139,9 @@ "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EXT_EXTERNAL_TEMP": "21.5", + "EXT_EXTERNAL_TEMP": None, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -188,18 +189,14 @@ "AVG_FUEL_CONSUMPTION": "102.3", "BATTERY_VOLTAGE": "12.0", "DISTANCE_TO_EMPTY_FUEL": "439.3", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "17", + "EV_DISTANCE_TO_EMPTY": "1", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "unknown", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_STATE_OF_CHARGE_PERCENT": "20", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "70.7", "ODOMETER": "766.8", "POSITION_HEADING_DEGREE": "150", @@ -207,7 +204,7 @@ "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "37.0", + "TYRE_PRESSURE_FRONT_LEFT": "0.0", "TYRE_PRESSURE_FRONT_RIGHT": "37.0", "TYRE_PRESSURE_REAR_LEFT": "35.5", "TYRE_PRESSURE_REAR_RIGHT": "34.1", @@ -221,18 +218,14 @@ "AVG_FUEL_CONSUMPTION": "2.3", "BATTERY_VOLTAGE": "12.0", "DISTANCE_TO_EMPTY_FUEL": "707", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "27.4", + "EV_DISTANCE_TO_EMPTY": "1.6", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "unknown", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_STATE_OF_CHARGE_PERCENT": "20", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "21.5", "ODOMETER": "1234", "POSITION_HEADING_DEGREE": "150", @@ -240,7 +233,7 @@ "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "2550", + "TYRE_PRESSURE_FRONT_LEFT": "0", "TYRE_PRESSURE_FRONT_RIGHT": "2550", "TYRE_PRESSURE_REAR_LEFT": "2450", "TYRE_PRESSURE_REAR_RIGHT": "2350", @@ -250,6 +243,7 @@ "longitude": -100.0, } + EXPECTED_STATE_EV_UNAVAILABLE = { "AVG_FUEL_CONSUMPTION": "unavailable", "BATTERY_VOLTAGE": "unavailable", @@ -261,11 +255,7 @@ "EV_IS_PLUGGED_IN": "unavailable", "EV_STATE_OF_CHARGE_MODE": "unavailable", "EV_STATE_OF_CHARGE_PERCENT": "unavailable", - "EV_TIME_TO_FULLY_CHARGED": "unavailable", - "EV_VEHICLE_TIME_DAYOFWEEK": "unavailable", - "EV_VEHICLE_TIME_HOUR": "unavailable", - "EV_VEHICLE_TIME_MINUTE": "unavailable", - "EV_VEHICLE_TIME_SECOND": "unavailable", + "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "EXT_EXTERNAL_TEMP": "unavailable", "ODOMETER": "unavailable", "POSITION_HEADING_DEGREE": "unavailable", diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 53bd04e7e55dff..20d70a7d496f9a 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -5,6 +5,7 @@ import pytest from subarulink.const import COUNTRY_USA +from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.subaru.const import ( CONF_COUNTRY, @@ -71,7 +72,17 @@ CONF_UPDATE_ENABLED: True, } -TEST_ENTITY_ID = "sensor.test_vehicle_2_odometer" +TEST_CONFIG_ENTRY = { + "entry_id": "1", + "domain": DOMAIN, + "title": TEST_CONFIG[CONF_USERNAME], + "data": TEST_CONFIG, + "options": TEST_OPTIONS, + "source": config_entries.SOURCE_USER, +} + +TEST_DEVICE_NAME = "test_vehicle_2" +TEST_ENTITY_ID = f"sensor.{TEST_DEVICE_NAME}_odometer" def advance_time_to_next_fetch(hass): @@ -80,26 +91,16 @@ def advance_time_to_next_fetch(hass): async_fire_time_changed(hass, future) -async def setup_subaru_integration( +async def setup_subaru_config_entry( hass, - vehicle_list=None, - vehicle_data=None, - vehicle_status=None, + config_entry, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, connect_effect=None, fetch_effect=None, ): - """Create Subaru entry.""" - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, DOMAIN, {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONFIG, - options=TEST_OPTIONS, - entry_id=1, - ) - config_entry.add_to_hass(hass) - + """Run async_setup with API mocks in place.""" with patch( MOCK_API_CONNECT, return_value=connect_effect is None, @@ -133,20 +134,22 @@ async def setup_subaru_integration( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + +@pytest.fixture +async def subaru_config_entry(hass): + """Create a Subaru config entry prior to setup.""" + await async_setup_component(hass, HA_DOMAIN, {}) + config_entry = MockConfigEntry(**TEST_CONFIG_ENTRY) + config_entry.add_to_hass(hass) return config_entry @pytest.fixture -async def ev_entry(hass): +async def ev_entry(hass, subaru_config_entry): """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" - entry = await setup_subaru_integration( - hass, - vehicle_list=[TEST_VIN_2_EV], - vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], - vehicle_status=VEHICLE_STATUS_EV, - ) + await setup_subaru_config_entry(hass, subaru_config_entry) assert DOMAIN in hass.config_entries.async_domains() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_get_entry(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - return entry + assert hass.config_entries.async_get_entry(subaru_config_entry.entry_id) + assert subaru_config_entry.state is ConfigEntryState.LOADED + return subaru_config_entry diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index cd87ed40315d3c..46a8b2e103b3b1 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -24,7 +24,7 @@ MOCK_API_FETCH, MOCK_API_UPDATE, TEST_ENTITY_ID, - setup_subaru_integration, + setup_subaru_config_entry, ) @@ -42,61 +42,70 @@ async def test_setup_ev(hass, ev_entry): assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g2(hass): +async def test_setup_g2(hass, subaru_config_entry): """Test setup with a G2 vehcile .""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_3_G2], vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], vehicle_status=VEHICLE_STATUS_G2, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g1(hass): +async def test_setup_g1(hass, subaru_config_entry): """Test setup with a G1 vehicle.""" - entry = await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_unsuccessful_connect(hass): +async def test_unsuccessful_connect(hass, subaru_config_entry): """Test unsuccessful connect due to connectivity.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=SubaruException("Service Unavailable"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_RETRY -async def test_invalid_credentials(hass): +async def test_invalid_credentials(hass, subaru_config_entry): """Test invalid credentials.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=InvalidCredentials("Invalid Credentials"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_ERROR -async def test_update_skip_unsubscribed(hass): +async def test_update_skip_unsubscribed(hass, subaru_config_entry): """Test update function skips vehicles without subscription.""" - await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) with patch(MOCK_API_FETCH) as mock_fetch: @@ -126,10 +135,11 @@ async def test_update_disabled(hass, ev_entry): mock_update.assert_not_called() -async def test_fetch_failed(hass): +async def test_fetch_failed(hass, subaru_config_entry): """Tests when fetch fails.""" - await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index 6ad5e729290390..caec43d36e8ab1 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -1,33 +1,38 @@ """Test Subaru sensors.""" from unittest.mock import patch -from homeassistant.components.subaru.const import VEHICLE_NAME +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.subaru.sensor import ( API_GEN_2_SENSORS, + DOMAIN as SUBARU_DOMAIN, EV_SENSORS, SAFETY_SENSORS, - SENSOR_FIELD, - SENSOR_TYPE, ) +from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api_responses import ( EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, - VEHICLE_DATA, VEHICLE_STATUS_EV, ) -from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch - -VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_GET_DATA, + TEST_DEVICE_NAME, + advance_time_to_next_fetch, + setup_subaru_config_entry, +) async def test_sensors_ev_imperial(hass, ev_entry): """Test sensors supporting imperial units.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM with patch(MOCK_API_FETCH), patch( MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV @@ -52,6 +57,84 @@ async def test_sensors_missing_vin_data(hass, ev_entry): _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ), + ], +) +async def test_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, + ) + assert entity.unique_id == old_unique_id + + await setup_subaru_config_entry(hass, subaru_config_entry) + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ) + ], +) +async def test_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, + ) + assert entity.unique_id == old_unique_id + + # create existing entry with new_unique_id that conflicts with migrate + existing_entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + SUBARU_DOMAIN, + unique_id=new_unique_id, + config_entry=subaru_config_entry, + ) + + await setup_subaru_config_entry(hass, subaru_config_entry) + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == old_unique_id + + entity_not_changed = entity_registry.async_get(existing_entity.entity_id) + assert entity_not_changed + assert entity_not_changed.unique_id == new_unique_id + + assert entity_migrated != entity_not_changed + + def _assert_data(hass, expected_state): sensor_list = EV_SENSORS sensor_list.extend(API_GEN_2_SENSORS) @@ -59,9 +142,9 @@ def _assert_data(hass, expected_state): expected_states = {} for item in sensor_list: expected_states[ - f"sensor.{slugify(f'{VEHICLE_NAME} {item[SENSOR_TYPE]}')}" - ] = expected_state[item[SENSOR_FIELD]] + f"sensor.{slugify(f'{TEST_DEVICE_NAME} {item.name}')}" + ] = expected_state[item.key] - for sensor in expected_states: + for sensor, value in expected_states.items(): actual = hass.states.get(sensor) - assert actual.state == expected_states[sensor] + assert actual.state == value diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 13aa6d487919fd..6f0f26f5f7ab07 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -18,9 +18,7 @@ async def test_setting_rising(hass): """Test retrieving sun setting and rising.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) with freeze_time(utc_now): - await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() state = hass.states.get(sun.ENTITY_ID) @@ -112,9 +110,7 @@ async def test_state_change(hass, caplog): """Test if the state changes at next setting/rising.""" now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) with freeze_time(now): - await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() @@ -167,9 +163,7 @@ async def test_norway_in_june(hass): june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=june): - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) state = hass.states.get(sun.ENTITY_ID) assert state is not None @@ -195,9 +189,7 @@ async def test_state_change_count(hass): now = datetime(2016, 6, 1, tzinfo=dt_util.UTC) with freeze_time(now): - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) events = [] diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index 547bf44ec5f94e..d8766beaa2b51d 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -26,7 +26,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test sun attributes to be excluded.""" await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 5f2275dca343d2..a2f0fca8b7e768 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -32,7 +32,7 @@ def setup_comp(hass): """Initialize components.""" mock_component(hass, "group") hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) ) diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index f30f72892ba834..d5216cf6262290 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -2,13 +2,13 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data DOMAIN = "switchbot" @@ -62,7 +62,7 @@ async def init_integration( address="AA:BB:CC:DD:EE:FF", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -82,7 +82,7 @@ async def init_integration( address="aa:bb:cc:dd:ee:ff", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -102,7 +102,7 @@ async def init_integration( address="798A8547-2A3D-C609-55FF-73FA824B923B", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"}, @@ -122,7 +122,7 @@ async def init_integration( address="cc:cc:cc:cc:cc:cc", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -140,7 +140,7 @@ async def init_integration( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoCurtain", manufacturer_data={89: b"\xc1\xc7'}U\xab"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"}, @@ -159,7 +159,7 @@ async def init_integration( service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, ), @@ -176,7 +176,7 @@ async def init_integration( service_data={}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( manufacturer_data={}, service_data={}, ), diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index ae77c5a8de4074..9de1403a6342ae 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -48,10 +48,12 @@ async def test_sensors(hass, entity_registry_enabled_by_default): assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" - rssi_sensor = hass.states.get("sensor.test_name_rssi") + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal_strength") rssi_sensor_attrs = rssi_sensor.attributes assert rssi_sensor.state == "-60" - assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Rssi" + assert ( + rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal strength" + ) assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 3578e3ac6c9c40..7fff1c476fb5dd 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -43,11 +43,19 @@ def mock_api(): patchers = [ patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.disconnect", + new=api_mock, + ), + patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.connect", + new=api_mock, + ), + patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.disconnect", new=api_mock, ), ] diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index e200d92e026045..eaf6a69cb3db75 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -3,8 +3,14 @@ from aioswitcher.device import ( DeviceState, DeviceType, + ShutterDirection, SwitcherPowerPlug, + SwitcherShutter, + SwitcherThermostat, SwitcherWaterHeater, + ThermostatFanLevel, + ThermostatMode, + ThermostatSwing, ) from homeassistant.components.switcher_kis import ( @@ -18,20 +24,36 @@ DUMMY_AUTO_SHUT_DOWN = "02:00:00" DUMMY_DEVICE_ID1 = "a123bc" DUMMY_DEVICE_ID2 = "cafe12" +DUMMY_DEVICE_ID3 = "bada77" +DUMMY_DEVICE_ID4 = "bbd164" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" +DUMMY_DEVICE_NAME3 = "Breeze AB39" +DUMMY_DEVICE_NAME4 = "Runner DD77" DUMMY_DEVICE_PASSWORD = "12345678" DUMMY_ELECTRIC_CURRENT1 = 0.5 DUMMY_ELECTRIC_CURRENT2 = 12.8 DUMMY_IP_ADDRESS1 = "192.168.100.157" DUMMY_IP_ADDRESS2 = "192.168.100.158" +DUMMY_IP_ADDRESS3 = "192.168.100.159" +DUMMY_IP_ADDRESS4 = "192.168.100.160" DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" +DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA" +DUMMY_MAC_ADDRESS4 = "A1:B2:C3:45:67:DB" DUMMY_PHONE_ID = "1234" DUMMY_POWER_CONSUMPTION1 = 100 DUMMY_POWER_CONSUMPTION2 = 2780 DUMMY_REMAINING_TIME = "01:29:32" DUMMY_TIMER_MINUTES_SET = "90" +DUMMY_THERMOSTAT_MODE = ThermostatMode.COOL +DUMMY_TEMPERATURE = 24.1 +DUMMY_TARGET_TEMPERATURE = 23 +DUMMY_FAN_LEVEL = ThermostatFanLevel.LOW +DUMMY_SWING = ThermostatSwing.OFF +DUMMY_REMOTE_ID = "ELEC7001" +DUMMY_POSITION = 54 +DUMMY_DIRECTION = ShutterDirection.SHUTTER_STOP YAML_CONFIG = { DOMAIN: { @@ -65,4 +87,30 @@ DUMMY_AUTO_SHUT_DOWN, ) +DUMMY_SHUTTER_DEVICE = SwitcherShutter( + DeviceType.RUNNER, + DeviceState.ON, + DUMMY_DEVICE_ID4, + DUMMY_IP_ADDRESS4, + DUMMY_MAC_ADDRESS4, + DUMMY_DEVICE_NAME4, + DUMMY_POSITION, + DUMMY_DIRECTION, +) + +DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( + DeviceType.BREEZE, + DeviceState.ON, + DUMMY_DEVICE_ID3, + DUMMY_IP_ADDRESS3, + DUMMY_MAC_ADDRESS3, + DUMMY_DEVICE_NAME3, + DUMMY_THERMOSTAT_MODE, + DUMMY_TEMPERATURE, + DUMMY_TARGET_TEMPERATURE, + DUMMY_FAN_LEVEL, + DUMMY_SWING, + DUMMY_REMOTE_ID, +) + DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py new file mode 100644 index 00000000000000..56fbbe61ef9d0d --- /dev/null +++ b/tests/components/switcher_kis/test_climate.py @@ -0,0 +1,341 @@ +"""Test the Switcher climate platform.""" +from unittest.mock import ANY, patch + +from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.device import ( + DeviceState, + ThermostatFanLevel, + ThermostatMode, + ThermostatSwing, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_THERMOSTAT_DEVICE as DEVICE + +ENTITY_ID = f"{CLIMATE_DOMAIN}.{slugify(DEVICE.name)}" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_hvac_mode(hass, mock_bridge, mock_api, monkeypatch): + """Test climate hvac mode service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test set hvac mode heat + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.HEAT) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + # Test set hvac mode off + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ANY, state=DeviceState.OFF) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_temperature(hass, mock_bridge, mock_api, monkeypatch): + """Test climate temperature service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial target temperature + state = hass.states.get(ENTITY_ID) + assert state.attributes["temperature"] == 23 + + # Test set target temperature + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "target_temperature", 22) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ANY, target_temp=22) + state = hass.states.get(ENTITY_ID) + assert state.attributes["temperature"] == 22 + + # Test set target temperature - incorrect params + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_not_called() + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_fan_level(hass, mock_bridge, mock_api, monkeypatch): + """Test climate fan level service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial fan level - low + state = hass.states.get(ENTITY_ID) + assert state.attributes["fan_mode"] == "low" + + # Test set fan level to high + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "fan_level", ThermostatFanLevel.HIGH) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, fan_level=ThermostatFanLevel.HIGH + ) + state = hass.states.get(ENTITY_ID) + assert state.attributes["fan_mode"] == "high" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_swing(hass, mock_bridge, mock_api, monkeypatch): + """Test climate swing service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial swing mode + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "off" + + # Test set swing mode on + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_SWING_MODE: "vertical", + }, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "swing", ThermostatSwing.ON) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.ON) + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "vertical" + + # Test set swing mode off + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "off"}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "swing", ThermostatSwing.OFF) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.OFF) + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "off" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_control_device_fail(hass, mock_bridge, mock_api, monkeypatch): + """Test control device fail.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test exception during set hvac mode + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_bad_update_discard(hass, mock_bridge, mock_api, monkeypatch): + """Test that a bad update from device is discarded.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Device send target temperature with 0 to indicate it doesn't have data + monkeypatch.setattr(DEVICE, "target_temperature", 0) + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.HEAT) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + # Validate state did not change + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_control_errors(hass, mock_bridge, mock_api, monkeypatch): + """Test control with settings not supported by device.""" + await init_integration(hass) + assert mock_bridge + + # Dry mode does not support setting fan, temperature, swing + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.DRY) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + # Test exception when trying set temperature + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, + blocking=True, + ) + + # Test exception when trying set fan level + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + + # Test exception when trying set swing mode + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "off"}, + blocking=True, + ) diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py new file mode 100644 index 00000000000000..a4c8b84dadb904 --- /dev/null +++ b/tests/components/switcher_kis/test_cover.py @@ -0,0 +1,183 @@ +"""Test the Switcher cover platform.""" +from unittest.mock import patch + +from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.device import ShutterDirection +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_SHUTTER_DEVICE as DEVICE + +ENTITY_ID = f"{COVER_DOMAIN}.{slugify(DEVICE.name)}" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_cover(hass, mock_bridge, mock_api, monkeypatch): + """Test cover services.""" + await init_integration(hass) + assert mock_bridge + + # Test initial state - open + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 77}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "position", 77) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(77) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 77 + + # Test open + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_UP) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(100) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPENING + + # Test close + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_DOWN) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 6 + mock_control_device.assert_called_once_with(0) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_CLOSING + + # Test stop + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.stop" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_STOP) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 8 + mock_control_device.assert_called_once() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test closed on position == 0 + monkeypatch.setattr(DEVICE, "position", 0) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_cover_control_fail(hass, mock_bridge, mock_api): + """Test cover control fail.""" + await init_integration(hass) + assert mock_bridge + + # Test initial state - open + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test exception during set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 44}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(44) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test error response during set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 27}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(27) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py new file mode 100644 index 00000000000000..8655ba7ee1f98e --- /dev/null +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -0,0 +1,59 @@ +"""Tests for the diagnostics data provided by Switcher.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import init_integration +from .consts import DUMMY_WATER_HEATER_DEVICE + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, mock_bridge, monkeypatch +) -> None: + """Test diagnostics.""" + entry = await init_integration(hass) + device = DUMMY_WATER_HEATER_DEVICE + monkeypatch.setattr(device, "last_data_update", "2022-09-28T16:42:12.706017") + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "devices": [ + { + "auto_shutdown": "02:00:00", + "device_id": REDACTED, + "device_state": { + "__type": "", + "repr": "", + }, + "device_type": { + "__type": "", + "repr": ")>", + }, + "electric_current": 12.8, + "ip_address": REDACTED, + "last_data_update": "2022-09-28T16:42:12.706017", + "mac_address": REDACTED, + "name": "Heater FE12", + "power_consumption": 2780, + "remaining_time": "01:29:32", + } + ], + "entry": { + "entry_id": entry.entry_id, + "version": 1, + "domain": "switcher_kis", + "title": "Mock Title", + "data": {}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": "switcher_kis", + "disabled_by": None, + }, + } diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index 9b0fcee27df7b0..cfc51402be0a3d 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -44,7 +44,7 @@ async def test_turn_on_with_timer_service(hass, mock_bridge, mock_api, monkeypat assert state.state == STATE_OFF with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" ) as mock_control_device: await hass.services.async_call( DOMAIN, @@ -74,7 +74,7 @@ async def test_set_auto_off_service(hass, mock_bridge, mock_api): entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown" ) as mock_set_auto_shutdown: await hass.services.async_call( DOMAIN, @@ -99,7 +99,7 @@ async def test_set_auto_off_service_fail(hass, mock_bridge, mock_api, caplog): entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown", return_value=None, ) as mock_set_auto_shutdown: await hass.services.async_call( diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index a44e0c796110e1..447de2352fe770 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -43,7 +43,7 @@ async def test_switch(hass, mock_bridge, mock_api, monkeypatch): # Test turning on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -56,7 +56,7 @@ async def test_switch(hass, mock_bridge, mock_api, monkeypatch): # Test turning off with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -87,7 +87,7 @@ async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, cap # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: await hass.services.async_call( @@ -111,7 +111,7 @@ async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, cap # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: await hass.services.async_call( diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 96e5480acb5565..53cb6531aaf5aa 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -136,6 +136,28 @@ async def test_warning(hass, hass_ws_client): assert_log(log, "", "warning message", "WARNING") +async def test_warning_good_format(hass, hass_ws_client): + """Test that warning with good format arguments are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + await hass.async_block_till_done() + _LOGGER.warning("warning message: %s", "test") + await hass.async_block_till_done() + + log = find_log(await get_error_log(hass_ws_client), "WARNING") + assert_log(log, "", "warning message: test", "WARNING") + + +async def test_warning_missing_format_args(hass, hass_ws_client): + """Test that warning with missing format arguments are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + await hass.async_block_till_done() + _LOGGER.warning("warning message missing a format arg %s") + await hass.async_block_till_done() + + log = find_log(await get_error_log(hass_ws_client), "WARNING") + assert_log(log, "", ["warning message missing a format arg %s"], "WARNING") + + async def test_error(hass, hass_ws_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 30bb9e00d59e10..503def425c286b 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -491,7 +491,7 @@ async def test_set_percentage(hass, calls): for state, value in [ (STATE_ON, 100), (STATE_ON, 66), - (STATE_OFF, 0), + (STATE_ON, 0), ]: await common.async_set_percentage(hass, _TEST_FAN, value) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value @@ -516,7 +516,7 @@ async def test_increase_decrease_speed(hass, calls): (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), (common.async_decrease_speed, None, STATE_ON, 33), - (common.async_decrease_speed, None, STATE_OFF, 0), + (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ]: await func(hass, _TEST_FAN, extra) @@ -524,6 +524,116 @@ async def test_increase_decrease_speed(hass, calls): _verify(hass, state, value, None, None, None) +async def test_no_value_template(hass, calls): + """Test a fan without a value_template.""" + await _register_fan_sources(hass) + + with assert_setup_component(1, "fan"): + test_fan_config = { + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "percentage_template": "{{ states('input_number.percentage') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": [ + { + "service": "input_boolean.turn_on", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "turn_off": [ + { + "service": "input_boolean.turn_off", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "set_preset_mode": [ + { + "service": "input_select.select_option", + "data_template": { + "entity_id": _PRESET_MODE_INPUT_SELECT, + "option": "{{ preset_mode }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_preset_mode", + "caller": "{{ this.entity_id }}", + "option": "{{ preset_mode }}", + }, + }, + ], + "set_percentage": [ + { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": "{{ percentage }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_value", + "caller": "{{ this.entity_id }}", + "value": "{{ percentage }}", + }, + }, + ], + } + assert await setup.async_setup_component( + hass, + "fan", + {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await common.async_turn_on(hass, _TEST_FAN) + _verify(hass, STATE_ON, 0, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, 0, None, None, None) + + percent = 100 + await common.async_set_percentage(hass, _TEST_FAN, percent) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent + _verify(hass, STATE_ON, percent, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, None) + + preset = "auto" + await common.async_set_preset_mode(hass, _TEST_FAN, preset) + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset + _verify(hass, STATE_ON, percent, None, None, preset) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_set_direction(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_oscillate(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + async def test_increase_decrease_speed_default_speed_count(hass, calls): """Test set valid increase and decrease speed.""" await _register_components(hass) @@ -585,10 +695,7 @@ def _verify( assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def _register_components( - hass, speed_list=None, preset_modes=None, speed_count=None -): - """Register basic components for testing.""" +async def _register_fan_sources(hass): with assert_setup_component(1, "input_boolean"): assert await setup.async_setup_component( hass, "input_boolean", {"input_boolean": {"state": None}} @@ -630,6 +737,13 @@ async def _register_components( }, ) + +async def _register_components( + hass, speed_list=None, preset_modes=None, speed_count=None +): + """Register basic components for testing.""" + await _register_fan_sources(hass) + with assert_setup_component(1, "fan"): value_template = """ {% if is_state('input_boolean.state', 'on') %} diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 1d42c9826e910b..acd1d2a842eddd 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -15,7 +15,7 @@ def tibber_setup_fixture(): yield -async def test_show_config_form(hass, recorder_mock): +async def test_show_config_form(recorder_mock, hass): """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,7 +25,7 @@ async def test_show_config_form(hass, recorder_mock): assert result["step_id"] == "user" -async def test_create_entry(hass, recorder_mock): +async def test_create_entry(recorder_mock, hass): """Test create entry from user input.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -49,7 +49,7 @@ async def test_create_entry(hass, recorder_mock): assert result["data"] == test_data -async def test_flow_entry_already_exists(hass, recorder_mock, config_entry): +async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index c92104128203ce..38b5eb91a2f762 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -8,7 +8,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry -async def test_entry_diagnostics(hass, hass_client, recorder_mock, config_entry): +async def test_entry_diagnostics(recorder_mock, hass, hass_client, config_entry): """Test config entry diagnostics.""" with patch( "tibber.Tibber.update_info", diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index 8e0d24ffa9da66..745c434237bb3f 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -10,7 +10,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_async_setup_entry(hass, recorder_mock): +async def test_async_setup_entry(recorder_mock, hass): """Test setup Tibber.""" tibber_connection = AsyncMock() tibber_connection.name = "tibber" diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py index 0cb9a0080f6e23..474c784ec3c9a3 100644 --- a/tests/components/tile/conftest.py +++ b/tests/components/tile/conftest.py @@ -26,9 +26,9 @@ def api_fixture(hass, data_tile_details): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -42,7 +42,7 @@ def config_fixture(hass): } -@pytest.fixture(name="data_tile_details", scope="session") +@pytest.fixture(name="data_tile_details", scope="package") def data_tile_details_fixture(): """Define a Tile details data payload.""" return json.loads(load_fixture("tile_details_data.json", "tile")) @@ -59,9 +59,3 @@ async def setup_tile_fixture(hass, api, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@host.com" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 51b3db00c6e3fd..7721e5d36ac1d0 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import API_V4_ENTRY_DATA @@ -172,7 +172,7 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: """Test v4 sensor data.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) check_sensor_state(hass, O3, "91.35") check_sensor_state(hass, CO, "0.0") diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index e2b77ac1ed1746..19574a9ab424fa 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -3,7 +3,8 @@ from unittest.mock import patch -from aiounifi.websocket import SIGNAL_CONNECTION_STATE, SIGNAL_DATA +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketSignal, WebsocketState import pytest from homeassistant.helpers import device_registry as dr @@ -16,14 +17,27 @@ def mock_unifi_websocket(): """No real websocket allowed.""" with patch("aiounifi.controller.WSClient") as mock: - def make_websocket_call(data: dict | None = None, state: str = ""): + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + state: WebsocketState | None = None, + ): """Generate a websocket call.""" - if data: + if data and not message: mock.return_value.data = data - mock.call_args[1]["callback"](SIGNAL_DATA) + mock.call_args[1]["callback"](WebsocketSignal.DATA) + elif data and message: + if not isinstance(data, list): + data = [data] + mock.return_value.data = { + "meta": {"message": message.value}, + "data": data, + } + mock.call_args[1]["callback"](WebsocketSignal.DATA) elif state: mock.return_value.state = state - mock.call_args[1]["callback"](SIGNAL_CONNECTION_STATE) + mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE) else: raise NotImplementedError diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 5de99a3f4348da..3861c5b38bdf62 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -7,7 +7,9 @@ from unittest.mock import Mock, patch import aiounifi -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.event import EventKey +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -359,13 +361,13 @@ async def test_connection_state_signalling( # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() # Controller is once again connected @@ -396,21 +398,14 @@ async def test_wireless_client_event_calls_update_wireless_devices( "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", return_value=None, ) as wireless_clients_mock: - mock_unifi_websocket( - data={ - "meta": {"rc": "ok", "message": "events"}, - "data": [ - { - "datetime": "2020-01-20T19:37:04Z", - "user": "00:00:00:00:00:01", - "key": aiounifi.events.WIRELESS_CLIENT_CONNECTED, - "msg": "User[11:22:33:44:55:66] has connected to WLAN", - "time": 1579549024893, - } - ], - }, - ) - + event = { + "datetime": "2020-01-20T19:37:04Z", + "user": "00:00:00:00:00:01", + "key": EventKey.WIRELESS_CLIENT_CONNECTED.value, + "msg": "User[11:22:33:44:55:66] has connected to WLAN", + "time": 1579549024893, + } + mock_unifi_websocket(message=MessageKey.EVENT, data=event) assert wireless_clients_mock.assert_called_once @@ -423,7 +418,7 @@ async def test_reconnect_mechanism(hass, aioclient_mock, mock_unifi_websocket): f"https://{DEFAULT_HOST}:1234/api/login", status=HTTPStatus.BAD_GATEWAY ) - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert aioclient_mock.call_count == 0 @@ -459,7 +454,7 @@ async def test_reconnect_mechanism_exceptions( with patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.controller.UniFiController.reconnect" ) as mock_reconnect: - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index e5d53a6d882aa9..b8f1aa771a4362 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,13 +3,8 @@ from datetime import timedelta from unittest.mock import patch -from aiounifi.controller import ( - MESSAGE_CLIENT, - MESSAGE_CLIENT_REMOVED, - MESSAGE_DEVICE, - MESSAGE_EVENT, -) -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -62,12 +57,7 @@ async def test_tracked_wireless_clients( # Updated timestamp marks client as home client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -83,12 +73,7 @@ async def test_tracked_wireless_clients( # Same timestamp doesn't explicitly mark client as away - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -163,12 +148,7 @@ async def test_tracked_clients( # State change signalling works client_1["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_1], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_1) await hass.async_block_till_done() assert hass.states.get("device_tracker.client_1").state == STATE_HOME @@ -213,14 +193,8 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} has connected to AP[{client["ap_mac"]}] with SSID "{client["essid"]}" on "channel 44(na)"', "_id": "5ea331fa30c49e00f90ddc1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME # Disconnected event @@ -240,12 +214,7 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', "_id": "5ea32ff730c49e00f90dca1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -263,14 +232,8 @@ async def test_tracked_wireless_clients_event_source( # New data - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME # Disconnection event will be ignored @@ -290,12 +253,7 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', "_id": "5ea32ff730c49e00f90dca1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -355,19 +313,8 @@ async def test_tracked_devices( # State change signalling work device_1["next_interval"] = 20 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) device_2["next_interval"] = 50 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_2], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=[device_1, device_2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_HOME @@ -386,12 +333,7 @@ async def test_tracked_devices( # Disabled device is unavailable device_1["disabled"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_UNAVAILABLE @@ -425,12 +367,7 @@ async def test_remove_clients( # Remove client - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [client_1], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=client_1) await hass.async_block_till_done() await hass.async_block_till_done() @@ -480,14 +417,14 @@ async def test_controller_state_change( assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME @@ -728,20 +665,11 @@ async def test_option_ssid_filter( # Roams to SSID outside of filter client["essid"] = "other_ssid" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + # Data update while SSID filter is in effect shouldn't create the client client_on_ssid2["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() # SSID filter marks client as away @@ -759,18 +687,7 @@ async def test_option_ssid_filter( client["last_seen"] += 1 client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=[client, client_on_ssid2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -786,12 +703,7 @@ async def test_option_ssid_filter( assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() # Client won't go away until after next update @@ -799,12 +711,7 @@ async def test_option_ssid_filter( # Trigger update to get client marked as away client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() new_time = ( @@ -848,12 +755,7 @@ async def test_wireless_client_go_wired_issue( # Trigger wired bug client["last_seen"] += 1 client["is_wired"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug fix keeps client marked as wireless @@ -874,12 +776,7 @@ async def test_wireless_client_go_wired_issue( # Try to mark client as connected client["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Make sure it don't go online again until wired bug disappears @@ -890,12 +787,7 @@ async def test_wireless_client_go_wired_issue( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is no longer affected by wired bug and can be marked online @@ -934,12 +826,7 @@ async def test_option_ignore_wired_bug( # Trigger wired bug client["is_wired"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug in effect @@ -960,12 +847,7 @@ async def test_option_ignore_wired_bug( # Mark client as connected again client["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Ignoring wired bug allows client to go home again even while affected @@ -976,12 +858,7 @@ async def test_option_ignore_wired_bug( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is wireless and still connected diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 398c6c6c3f57e7..100918a93dae57 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -3,7 +3,7 @@ from datetime import datetime from unittest.mock import patch -from aiounifi.controller import MESSAGE_CLIENT, MESSAGE_CLIENT_REMOVED +from aiounifi.models.message import MessageKey import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -87,12 +87,7 @@ async def test_bandwidth_sensors(hass, aioclient_mock, mock_unifi_websocket): wireless_client["rx_bytes-r"] = 3456000000 wireless_client["tx_bytes-r"] = 7891000000 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [wireless_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) await hass.async_block_till_done() assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" @@ -199,12 +194,7 @@ async def test_uptime_sensors( uptime_client["uptime"] = event_uptime now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [uptime_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" @@ -215,12 +205,7 @@ async def test_uptime_sensors( uptime_client["uptime"] = new_uptime now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [uptime_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-02-01T01:00:00+00:00" @@ -308,12 +293,7 @@ async def test_remove_sensors(hass, aioclient_mock, mock_unifi_websocket): # Remove wired client - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [wired_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=wired_client) await hass.async_block_till_done() assert len(hass.states.async_all()) == 5 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 9965c25a1b5a68..e6357b031725b0 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,13 +1,17 @@ """UniFi Network switch platform tests.""" + from copy import deepcopy +from datetime import timedelta -from aiounifi.controller import MESSAGE_CLIENT_REMOVED, MESSAGE_DEVICE, MESSAGE_EVENT +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from homeassistant import config_entries, core from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SwitchDeviceClass, ) from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, @@ -18,10 +22,19 @@ DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.switch import POE_SWITCH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +from homeassistant.util import dt from .test_controller import ( CONTROLLER_HOST, @@ -31,7 +44,7 @@ setup_unifi_integration, ) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache CLIENT_1 = { "hostname": "client_1", @@ -41,7 +54,7 @@ "mac": "00:00:00:00:00:01", "name": "POE Client 1", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -54,7 +67,7 @@ "mac": "00:00:00:00:00:02", "name": "POE Client 2", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 2, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -67,7 +80,7 @@ "mac": "00:00:00:00:00:03", "name": "Non-POE Client 3", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 3, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -80,7 +93,7 @@ "mac": "00:00:00:00:00:04", "name": "Non-POE Client 4", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 4, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -94,7 +107,7 @@ "mac": "00:00:00:00:00:01", "name": "POE Client 1", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -107,7 +120,7 @@ "mac": "00:00:00:00:00:02", "name": "POE Client 2", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -115,9 +128,10 @@ ] DEVICE_1 = { + "board_rev": 2, "device_id": "mock-id", "ip": "10.0.1.1", - "mac": "00:00:00:00:01:01", + "mac": "10:00:00:00:01:01", "last_seen": 1562600145, "model": "US16P150", "name": "mock-name", @@ -400,9 +414,16 @@ "index": 1, "has_relay": True, "has_metering": False, + "relay_state": True, + "name": "Outlet 1", + }, + { + "index": 2, + "has_relay": False, + "has_metering": False, "relay_state": False, "name": "Outlet 1", - } + }, ], "element_ap_serial": "44:d9:e7:90:f4:24", "connected_at": 1641678609, @@ -629,7 +650,7 @@ async def test_switches(hass, aioclient_mock): assert switch_1 is not None assert switch_1.state == "on" assert switch_1.attributes["power"] == "2.56" - assert switch_1.attributes[SWITCH_DOMAIN] == "00:00:00:00:01:01" + assert switch_1.attributes[SWITCH_DOMAIN] == "10:00:00:00:01:01" assert switch_1.attributes["port"] == 1 assert switch_1.attributes["poe_mode"] == "auto" @@ -729,12 +750,7 @@ async def test_remove_switches(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("switch.block_client_2") is not None assert hass.states.get("switch.block_media_streaming") is not None - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [CLIENT_1, UNBLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[CLIENT_1, UNBLOCKED]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -745,7 +761,6 @@ async def test_remove_switches(hass, aioclient_mock, mock_unifi_websocket): mock_unifi_websocket(data=DPI_GROUP_REMOVED_EVENT) await hass.async_block_till_done() - await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -776,12 +791,7 @@ async def test_block_switches(hass, aioclient_mock, mock_unifi_websocket): assert unblocked is not None assert unblocked.state == "on" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_UNBLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_UNBLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -789,12 +799,7 @@ async def test_block_switches(hass, aioclient_mock, mock_unifi_websocket): assert blocked is not None assert blocked.state == "on" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_BLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -846,9 +851,20 @@ async def test_dpi_switches(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("switch.block_media_streaming").state == STATE_OFF - mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() + assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() + assert hass.states.get("switch.block_media_streaming").state == STATE_OFF + + # Remove app + mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None @@ -868,95 +884,71 @@ async def test_dpi_switches_add_second_app(hass, aioclient_mock, mock_unifi_webs assert hass.states.get("switch.block_media_streaming").state == STATE_ON second_app_event = { - "meta": {"rc": "ok", "message": "dpiapp:add"}, - "data": [ - { - "apps": [524292], - "blocked": False, - "cats": [], - "enabled": False, - "log": False, - "site_id": "name", - "_id": "61783e89c1773a18c0c61f00", - } - ], + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": False, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket(data=second_app_event) + mock_unifi_websocket(message=MessageKey.DPI_APP_ADDED, data=second_app_event) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_ON add_second_app_to_group = { - "meta": {"rc": "ok", "message": "dpigroup:sync"}, - "data": [ - { - "_id": "5f976f4ae3c58f018ec7dff6", - "name": "Block Media Streaming", - "site_id": "name", - "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], - } - ], + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], } - - mock_unifi_websocket(data=add_second_app_to_group) + mock_unifi_websocket( + message=MessageKey.DPI_GROUP_UPDATED, data=add_second_app_to_group + ) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF second_app_event_enabled = { - "meta": {"rc": "ok", "message": "dpiapp:sync"}, - "data": [ - { - "apps": [524292], - "blocked": False, - "cats": [], - "enabled": True, - "log": False, - "site_id": "name", - "_id": "61783e89c1773a18c0c61f00", - } - ], + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": True, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket(data=second_app_event_enabled) + mock_unifi_websocket( + message=MessageKey.DPI_APP_UPDATED, data=second_app_event_enabled + ) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_ON async def test_outlet_switches(hass, aioclient_mock, mock_unifi_websocket): - """Test the update_items function with some clients.""" + """Test the outlet entities.""" config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_DEVICES: False}, - devices_response=[OUTLET_UP1], + hass, aioclient_mock, devices_response=[OUTLET_UP1] ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - outlet = hass.states.get("switch.plug_outlet_1") - assert outlet is not None - assert outlet.state == STATE_OFF - - # State change - - outlet_up1 = deepcopy(OUTLET_UP1) - outlet_up1["outlet_table"][0]["relay_state"] = True + # Validate state object + switch_1 = hass.states.get("switch.plug_outlet_1") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [outlet_up1], - } - ) + # Update state object + device_1 = deepcopy(OUTLET_UP1) + device_1["outlet_table"][0]["relay_state"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF - outlet = hass.states.get("switch.plug_outlet_1") - assert outlet.state == STATE_ON - - # Turn on and off outlet - + # Turn off outlet aioclient_mock.clear_requests() aioclient_mock.put( f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", @@ -964,33 +956,50 @@ async def test_outlet_switches(hass, aioclient_mock, mock_unifi_websocket): await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, blocking=True, ) assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] } + # Turn on outlet await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, blocking=True, ) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] } - # Changes to config entry options shouldn't affect outlets - hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: []}, - ) + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + + # Device gets disabled + device_1["disabled"] = True + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF # Unload config entry await hass.config_entries.async_unload(config_entry.entry_id) @@ -1018,31 +1027,13 @@ async def test_new_client_discovered_on_block_control( ) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + assert hass.states.get("switch.block_client_1") is None - blocked = hass.states.get("switch.block_client_1") - assert blocked is None - - mock_unifi_websocket( - data={ - "meta": {"message": "sta:sync"}, - "data": [BLOCKED], - } - ) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 - - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_CONNECTED], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - blocked = hass.states.get("switch.block_client_1") - assert blocked is not None + assert hass.states.get("switch.block_client_1") is not None async def test_option_block_clients(hass, aioclient_mock): @@ -1128,22 +1119,12 @@ async def test_new_client_discovered_on_poe_control( assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - mock_unifi_websocket( - data={ - "meta": {"message": "sta:sync"}, - "data": [CLIENT_2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=CLIENT_2) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_CLIENT_2_CONNECTED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_CLIENT_2_CONNECTED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -1373,3 +1354,96 @@ async def test_restore_client_no_old_state(hass, aioclient_mock): poe_client = hass.states.get("switch.poe_client") assert poe_client.state == "unavailable" # self.poe_mode is None + + +async def test_poe_port_switches(hass, aioclient_mock, mock_unifi_websocket): + """Test the update_items function with some clients.""" + config_entry = await setup_unifi_integration( + hass, aioclient_mock, devices_response=[DEVICE_1] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.mock_name_port_1_poe") + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Enable entity + ent_reg.async_update_entity( + entity_id="switch.mock_name_port_1_poe", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + switch_1 = hass.states.get("switch.mock_name_port_1_poe") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET + + # Update state object + device_1 = deepcopy(DEVICE_1) + device_1["port_table"][0]["poe_mode"] = "off" + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Turn off PoE + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/mock-id", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "port_overrides": [{"poe_mode": "off", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Turn on PoE + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { + "port_overrides": [{"poe_mode": "auto", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Device gets disabled + device_1["disabled"] = True + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 677491319a1f1b..3c30da0b62dd0c 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -1,8 +1,8 @@ """The tests for the UniFi Network update platform.""" from copy import deepcopy -from aiounifi.controller import MESSAGE_DEVICE -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -64,9 +64,7 @@ async def test_no_entities(hass, aioclient_mock): assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 0 -async def test_device_updates( - hass, aioclient_mock, mock_unifi_websocket, mock_device_registry -): +async def test_device_updates(hass, aioclient_mock, mock_unifi_websocket): """Test the update_items function with some devices.""" device_1 = deepcopy(DEVICE_1) await setup_unifi_integration( @@ -102,12 +100,7 @@ async def test_device_updates( # Simulate start of update device_1["state"] = 4 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -122,12 +115,7 @@ async def test_device_updates( device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -188,9 +176,7 @@ async def test_install(hass, aioclient_mock): ) -async def test_controller_state_change( - hass, aioclient_mock, mock_unifi_websocket, mock_device_registry -): +async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): """Verify entities state reflect on controller becoming unavailable.""" await setup_unifi_integration( hass, @@ -202,13 +188,13 @@ async def test_controller_state_change( assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("update.device_1").state == STATE_ON diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index d1263a720af39b..46588ecf45d7ed 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -21,7 +21,7 @@ async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test update attributes to be excluded.""" platform = getattr(hass.components, f"test.{DOMAIN}") diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index b159a371d9ae5a..f26fb39e42a11f 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -1,11 +1,12 @@ """Configuration for SSDP tests.""" from __future__ import annotations +from datetime import datetime from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch from urllib.parse import urlparse from async_upnp_client.client import UpnpDevice -from async_upnp_client.profiles.igd import IgdDevice, StatusInfo +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo import pytest from homeassistant.components import ssdp @@ -65,16 +66,23 @@ def mock_igd_device() -> IgdDevice: mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device - mock_igd_device.async_get_total_bytes_received.return_value = 0 - mock_igd_device.async_get_total_bytes_sent.return_value = 0 - mock_igd_device.async_get_total_packets_received.return_value = 0 - mock_igd_device.async_get_total_packets_sent.return_value = 0 - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Connected", - "", - 10, + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=0, + bytes_sent=0, + packets_received=0, + packets_sent=0, + status_info=StatusInfo( + "Connected", + "", + 10, + ), + external_ip_address="8.9.10.11", + kibibytes_per_sec_received=None, + kibibytes_per_sec_sent=None, + packets_per_sec_received=None, + packets_per_sec_sent=None, ) - mock_igd_device.async_get_external_ip_address.return_value = "8.9.10.11" with patch( "homeassistant.components.upnp.device.UpnpFactory.async_create_device" @@ -122,6 +130,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 24e5cdce47cb6d..769a5d790c8678 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -1,8 +1,8 @@ """Tests for UPnP/IGD binary_sensor.""" -from datetime import timedelta +from datetime import datetime, timedelta -from async_upnp_client.profiles.igd import StatusInfo +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -20,11 +20,23 @@ async def test_upnp_binary_sensors( assert wan_status_state.state == "on" # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Disconnected", - "", - 40, + mock_igd_device: IgdDevice = mock_config_entry.igd_device + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=0, + bytes_sent=0, + packets_received=0, + packets_sent=0, + status_info=StatusInfo( + "Disconnected", + "", + 40, + ), + external_ip_address="8.9.10.11", + kibibytes_per_sec_received=None, + kibibytes_per_sec_sent=None, + packets_per_sec_received=None, + packets_per_sec_sent=None, ) async_fire_time_changed( diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 2abd357ac31b3f..f5eb69bfae9c4f 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -1,10 +1,8 @@ """Tests for UPnP/IGD sensor.""" -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta -from async_upnp_client.profiles.igd import StatusInfo -import pytest +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -14,7 +12,7 @@ async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEntry): - """Test normal sensors.""" + """Test sensors.""" # First poll. assert hass.states.get("sensor.mock_name_b_received").state == "0" assert hass.states.get("sensor.mock_name_b_sent").state == "0" @@ -22,19 +20,30 @@ async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEn assert hass.states.get("sensor.mock_name_packets_sent").state == "0" assert hass.states.get("sensor.mock_name_external_ip").state == "8.9.10.11" assert hass.states.get("sensor.mock_name_wan_status").state == "Connected" + assert hass.states.get("sensor.mock_name_kib_s_received").state == "unknown" + assert hass.states.get("sensor.mock_name_kib_s_sent").state == "unknown" + assert hass.states.get("sensor.mock_name_packets_s_received").state == "unknown" + assert hass.states.get("sensor.mock_name_packets_s_sent").state == "unknown" # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_total_bytes_received.return_value = 10240 - mock_igd_device.async_get_total_bytes_sent.return_value = 20480 - mock_igd_device.async_get_total_packets_received.return_value = 30 - mock_igd_device.async_get_total_packets_sent.return_value = 40 - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Disconnected", - "", - 40, + mock_igd_device: IgdDevice = mock_config_entry.igd_device + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=10240, + bytes_sent=20480, + packets_received=30, + packets_sent=40, + status_info=StatusInfo( + "Disconnected", + "", + 40, + ), + external_ip_address="", + kibibytes_per_sec_received=10.0, + kibibytes_per_sec_sent=20.0, + packets_per_sec_received=30.0, + packets_per_sec_sent=40.0, ) - mock_igd_device.async_get_external_ip_address.return_value = "" now = dt_util.utcnow() async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) @@ -46,50 +55,7 @@ async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEn assert hass.states.get("sensor.mock_name_packets_sent").state == "40" assert hass.states.get("sensor.mock_name_external_ip").state == "" assert hass.states.get("sensor.mock_name_wan_status").state == "Disconnected" - - -async def test_derived_upnp_sensors( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -): - """Test derived sensors.""" - # First poll. - assert hass.states.get("sensor.mock_name_kib_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_kib_s_sent").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_sent").state == "unknown" - - # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_total_bytes_received.return_value = int( - 10240 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_bytes_sent.return_value = int( - 20480 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_packets_received.return_value = int( - 30 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_packets_sent.return_value = int( - 40 * DEFAULT_SCAN_INTERVAL - ) - - now = dt_util.utcnow() - with patch( - "homeassistant.components.upnp.device.utcnow", - return_value=now + timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ): - async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) - await hass.async_block_till_done() - - assert float( - hass.states.get("sensor.mock_name_kib_s_received").state - ) == pytest.approx(10.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_kib_s_sent").state - ) == pytest.approx(20.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_packets_s_received").state - ) == pytest.approx(30.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_packets_s_sent").state - ) == pytest.approx(40.0, rel=0.1) + assert hass.states.get("sensor.mock_name_kib_s_received").state == "10.0" + assert hass.states.get("sensor.mock_name_kib_s_sent").state == "20.0" + assert hass.states.get("sensor.mock_name_packets_s_received").state == "30.0" + assert hass.states.get("sensor.mock_name_packets_s_sent").state == "40.0" diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 040cc9105aaab2..1ca796ba3643fb 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test vacuum registered attributes to be excluded.""" await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/vallox/test_binary_sensor.py b/tests/components/vallox/test_binary_sensor.py new file mode 100644 index 00000000000000..a1bd02cf950ab6 --- /dev/null +++ b/tests/components/vallox/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Tests for Vallox binary sensor platform.""" +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from .conftest import patch_metrics + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "metrics,expected_state", + [ + ({"A_CYC_IO_HEATER": 1}, "on"), + ({"A_CYC_IO_HEATER": 0}, "off"), + ], +) +async def test_binary_sensor_entitity( + metrics: dict[str, Any], + expected_state: str, + mock_entry: MockConfigEntry, + hass: HomeAssistant, +): + """Test binary sensor with metrics.""" + # Act + with patch_metrics(metrics=metrics): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("binary_sensor.vallox_post_heater") + assert sensor.state == expected_state diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 5e712c71b24eaa..f5059517e3e36a 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -246,8 +246,10 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: "host": "1.1.1.1", "port": 8888, "name": "custom name", - "addon": "vlc", - } + "addon": "VLC", + }, + name="VLC", + slug="vlc", ) result = await hass.config_entries.flow.async_init( @@ -284,7 +286,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=entry_data), + data=HassioServiceInfo(config=entry_data, name="VLC", slug="vlc"), ) await hass.async_block_till_done() @@ -324,8 +326,10 @@ async def test_hassio_errors( "host": "1.1.1.1", "port": 8888, "name": "custom name", - "addon": "vlc", - } + "addon": "VLC", + }, + name="VLC", + slug="vlc", ), ) await hass.async_block_till_done() diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index b6670152e3f5f0..549cafc74458c8 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -20,7 +20,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test water_heater registered attributes to be excluded.""" await async_setup_component( hass, water_heater.DOMAIN, {water_heater.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py index 6483778c153aa5..f3c1986fcb021a 100644 --- a/tests/components/watttime/conftest.py +++ b/tests/components/watttime/conftest.py @@ -62,11 +62,13 @@ def config_location_type_fixture(hass): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config_auth, config_coordinates, unique_id): +def config_entry_fixture(hass, config_auth, config_coordinates): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=unique_id, + unique_id=( + f"{config_coordinates[CONF_LATITUDE]}, {config_coordinates[CONF_LONGITUDE]}" + ), data={ **config_auth, **config_coordinates, @@ -78,13 +80,13 @@ def config_entry_fixture(hass, config_auth, config_coordinates, unique_id): return entry -@pytest.fixture(name="data_grid_region", scope="session") +@pytest.fixture(name="data_grid_region", scope="package") def data_grid_region_fixture(): """Define grid region data.""" return json.loads(load_fixture("grid_region_data.json", "watttime")) -@pytest.fixture(name="data_realtime_emissions", scope="session") +@pytest.fixture(name="data_realtime_emissions", scope="package") def data_realtime_emissions_fixture(): """Define realtime emissions data.""" return json.loads(load_fixture("realtime_emissions_data.json", "watttime")) @@ -112,9 +114,3 @@ async def setup_watttime_fixture(hass, client, config_auth, config_coordinates): ) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "32.87336, -117.22743" diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py index 0d8d87203bbbf9..e5aaf65e9208c0 100644 --- a/tests/components/watttime/test_diagnostics.py +++ b/tests/components/watttime/test_diagnostics.py @@ -8,15 +8,24 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_watttime """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "watttime", + "title": REDACTED, "data": { "username": REDACTED, "password": REDACTED, "latitude": REDACTED, "longitude": REDACTED, - "balancing_authority": "PJM New Jersey", - "balancing_authority_abbreviation": "PJM_NJ", + "balancing_authority": REDACTED, + "balancing_authority_abbreviation": REDACTED, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "freq": "300", diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index c4b8144b74d768..d58f8d9a34d42f 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -14,9 +14,12 @@ CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_NAME, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, ) -from homeassistant.const import CONF_NAME, CONF_REGION, CONF_UNIT_SYSTEM_IMPERIAL +from homeassistant.const import CONF_NAME, CONF_REGION +from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG @@ -24,7 +27,7 @@ @pytest.mark.usefixtures("validate_config_entry") -async def test_minimum_fields(hass): +async def test_minimum_fields(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -48,11 +51,12 @@ async def test_minimum_fields(hass): } -async def test_options(hass): +async def test_options(hass: HomeAssistant) -> None: """Test options flow.""" entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, + options=DEFAULT_OPTIONS, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -72,7 +76,7 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", }, ) @@ -85,7 +89,7 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", } @@ -96,54 +100,13 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", } @pytest.mark.usefixtures("validate_config_entry") -async def test_import(hass): - """Test import for config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "taxi", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.data == { - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - } - assert entry.options == { - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "taxi", - } - - -@pytest.mark.usefixtures("validate_config_entry") -async def test_dupe(hass): +async def test_dupe(hass: HomeAssistant) -> None: """Test setting up the same entry data twice is OK.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -176,7 +139,9 @@ async def test_dupe(hass): @pytest.mark.usefixtures("invalidate_config_entry") -async def test_invalid_config_entry(hass): +async def test_invalid_config_entry( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -190,3 +155,48 @@ async def test_invalid_config_entry(hass): assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + assert "Error trying to validate entry" in caplog.text + + +@pytest.mark.usefixtures("mock_update") +async def test_reset_filters(hass: HomeAssistant) -> None: + """Test resetting inclusive and exclusive filters to empty string.""" + options = {**DEFAULT_OPTIONS} + options[CONF_INCL_FILTER] = "test" + options[CONF_EXCL_FILTER] = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=options, entry_id="test" + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, data=None + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_AVOID_FERRIES: True, + CONF_AVOID_SUBSCRIPTION_ROADS: True, + CONF_AVOID_TOLL_ROADS: True, + CONF_EXCL_FILTER: "", + CONF_INCL_FILTER: "", + CONF_REALTIME: False, + CONF_UNITS: IMPERIAL_UNITS, + CONF_VEHICLE_TYPE: "taxi", + }, + ) + + assert config_entry.options == { + CONF_AVOID_FERRIES: True, + CONF_AVOID_SUBSCRIPTION_ROADS: True, + CONF_AVOID_TOLL_ROADS: True, + CONF_EXCL_FILTER: "", + CONF_INCL_FILTER: "", + CONF_REALTIME: False, + CONF_UNITS: IMPERIAL_UNITS, + CONF_VEHICLE_TYPE: "taxi", + } diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py deleted file mode 100644 index bf8f6a95844049..00000000000000 --- a/tests/components/waze_travel_time/test_init.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test Waze Travel Time initialization.""" -from homeassistant.components.waze_travel_time.const import DOMAIN -from homeassistant.helpers.entity_registry import async_get - -from tests.common import MockConfigEntry - - -async def test_migration(hass, bypass_platform_setup): - """Test migration logic for unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, version=1, entry_id="test", unique_id="test" - ) - ent_reg = async_get(hass) - ent_entry = ent_reg.async_get_or_create( - "sensor", DOMAIN, unique_id="replaceable_unique_id", config_entry=config_entry - ) - entity_id = ent_entry.entity_id - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id is None - assert ent_reg.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index 67ba7c6e3115d5..569335f9e6c6a1 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -10,9 +10,10 @@ CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, ) -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL from .const import MOCK_CONFIG @@ -51,7 +52,7 @@ def mock_update_keyerror_fixture(mock_wrc): @pytest.mark.parametrize( "data,options", - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) @pytest.mark.usefixtures("mock_update", "mock_config") async def test_sensor(hass): @@ -84,7 +85,7 @@ async def test_sensor(hass): ( MOCK_CONFIG, { - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_REALTIME: True, CONF_VEHICLE_TYPE: "car", CONF_AVOID_TOLL_ROADS: True, @@ -105,7 +106,9 @@ async def test_imperial(hass): @pytest.mark.usefixtures("mock_update_wrcerror") async def test_sensor_failed_wrcerror(hass, caplog): """Test that sensor update fails with log message.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, entry_id="test" + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -117,7 +120,9 @@ async def test_sensor_failed_wrcerror(hass, caplog): @pytest.mark.usefixtures("mock_update_keyerror") async def test_sensor_failed_keyerror(hass, caplog): """Test that sensor update fails with log message.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, entry_id="test" + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 814d3b7857cfa3..4b4f3a82d077a3 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -54,7 +54,7 @@ from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.testing_config.custom_components.test import weather as WeatherPlatform @@ -143,7 +143,7 @@ async def create_entity(hass: HomeAssistant, **kwargs): @pytest.mark.parametrize("native_unit", (TEMP_FAHRENHEIT, TEMP_CELSIUS)) @pytest.mark.parametrize( "state_unit, unit_system", - ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, US_CUSTOMARY_SYSTEM)), ) async def test_temperature( hass: HomeAssistant, @@ -176,7 +176,7 @@ async def test_temperature( @pytest.mark.parametrize("native_unit", (None,)) @pytest.mark.parametrize( "state_unit, unit_system", - ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, US_CUSTOMARY_SYSTEM)), ) async def test_temperature_no_unit( hass: HomeAssistant, @@ -209,7 +209,7 @@ async def test_temperature_no_unit( @pytest.mark.parametrize("native_unit", (PRESSURE_INHG, PRESSURE_INHG)) @pytest.mark.parametrize( "state_unit, unit_system", - ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, US_CUSTOMARY_SYSTEM)), ) async def test_pressure( hass: HomeAssistant, @@ -237,7 +237,7 @@ async def test_pressure( @pytest.mark.parametrize("native_unit", (None,)) @pytest.mark.parametrize( "state_unit, unit_system", - ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, US_CUSTOMARY_SYSTEM)), ) async def test_pressure_no_unit( hass: HomeAssistant, @@ -270,7 +270,7 @@ async def test_pressure_no_unit( "state_unit, unit_system", ( (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), - (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + (SPEED_MILES_PER_HOUR, US_CUSTOMARY_SYSTEM), ), ) async def test_wind_speed( @@ -304,7 +304,7 @@ async def test_wind_speed( "state_unit, unit_system", ( (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), - (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + (SPEED_MILES_PER_HOUR, US_CUSTOMARY_SYSTEM), ), ) async def test_wind_speed_no_unit( @@ -338,7 +338,7 @@ async def test_wind_speed_no_unit( "state_unit, unit_system", ( (LENGTH_KILOMETERS, METRIC_SYSTEM), - (LENGTH_MILES, IMPERIAL_SYSTEM), + (LENGTH_MILES, US_CUSTOMARY_SYSTEM), ), ) async def test_visibility( @@ -369,7 +369,7 @@ async def test_visibility( "state_unit, unit_system", ( (LENGTH_KILOMETERS, METRIC_SYSTEM), - (LENGTH_MILES, IMPERIAL_SYSTEM), + (LENGTH_MILES, US_CUSTOMARY_SYSTEM), ), ) async def test_visibility_no_unit( @@ -400,7 +400,7 @@ async def test_visibility_no_unit( "state_unit, unit_system", ( (LENGTH_MILLIMETERS, METRIC_SYSTEM), - (LENGTH_INCHES, IMPERIAL_SYSTEM), + (LENGTH_INCHES, US_CUSTOMARY_SYSTEM), ), ) async def test_precipitation( @@ -431,7 +431,7 @@ async def test_precipitation( "state_unit, unit_system", ( (LENGTH_MILLIMETERS, METRIC_SYSTEM), - (LENGTH_INCHES, IMPERIAL_SYSTEM), + (LENGTH_INCHES, US_CUSTOMARY_SYSTEM), ), ) async def test_precipitation_no_unit( @@ -719,7 +719,7 @@ async def test_backwards_compatibility_convert_values( precipitation_value = 1 precipitation_unit = LENGTH_MILLIMETERS - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM platform: WeatherPlatform = getattr(hass.components, "test.weather") platform.init(empty=True) diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index ef1998f734c7f2..7e113ba9b8329b 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -15,7 +15,7 @@ from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass: HomeAssistant, recorder_mock) -> None: +async def test_exclude_attributes(recorder_mock, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 05f1be66d00cb9..c8333c844472bf 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -39,6 +39,8 @@ def client_fixture(): client.sound_output = "speaker" client.muted = False client.is_on = True + client.is_registered = Mock(return_value=True) + client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): await client.register_state_update_callback.call_args[0][0](client) diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py new file mode 100644 index 00000000000000..707f83b2fcf11c --- /dev/null +++ b/tests/components/webostv/test_diagnostics.py @@ -0,0 +1,61 @@ +"""Tests for the diagnostics data provided by LG webOS Smart TV.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import setup_webostv + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, client +) -> None: + """Test diagnostics.""" + entry = await setup_webostv(hass) + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "client": { + "is_registered": True, + "is_connected": True, + "current_app_id": "com.webos.app.livetv", + "current_channel": { + "channelId": "ch1id", + "channelName": "Channel 1", + "channelNumber": "1", + }, + "apps": { + "com.webos.app.livetv": { + "icon": REDACTED, + "id": "com.webos.app.livetv", + "largeIcon": REDACTED, + "title": "Live TV", + } + }, + "inputs": { + "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, + "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, + }, + "system_info": {"modelName": "TVFAKE"}, + "software_info": {"major_ver": "major", "minor_ver": "minor"}, + "hello_info": {"deviceUUID": "**REDACTED**"}, + "sound_output": "speaker", + "is_on": True, + }, + "entry": { + "entry_id": entry.entry_id, + "version": 1, + "domain": "webostv", + "title": "fake_webos", + "data": { + "client_secret": "**REDACTED**", + "host": "**REDACTED**", + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + } diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 27ae21db6e2113..a865776f74df57 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -14,7 +14,7 @@ TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL -from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity @@ -23,13 +23,7 @@ from homeassistant.loader import async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_setup_component -from tests.common import ( - MockEntity, - MockEntityPlatform, - MockModule, - async_mock_service, - mock_integration, -) +from tests.common import MockEntity, MockEntityPlatform, async_mock_service STATE_KEY_SHORT_NAMES = { "entity_id": "e", @@ -1712,7 +1706,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( message = {"august": 12.5, "isy994": 12.8} - async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, message) + async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() assert msg["id"] == 7 assert msg["type"] == "event" @@ -1794,45 +1788,6 @@ async def test_validate_config_invalid(websocket_client, key, config, error): assert msg["result"] == {key: {"valid": False, "error": error}} -async def test_supported_brands(hass, websocket_client): - """Test supported brands.""" - # Custom components without supported brands that override a built-in component with - # supported brand will still be listed in HAS_SUPPORTED_BRANDS and should be ignored. - mock_integration( - hass, - MockModule("override_without_brands"), - ) - mock_integration( - hass, - MockModule("test", partial_manifest={"supported_brands": {"hello": "World"}}), - ) - mock_integration( - hass, - MockModule( - "abcd", partial_manifest={"supported_brands": {"something": "Something"}} - ), - ) - - with patch( - "homeassistant.generated.supported_brands.HAS_SUPPORTED_BRANDS", - ("abcd", "test", "override_without_brands"), - ): - await websocket_client.send_json({"id": 7, "type": "supported_brands"}) - msg = await websocket_client.receive_json() - - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "abcd": { - "something": "Something", - }, - "test": { - "hello": "World", - }, - } - - async def test_message_coalescing(hass, websocket_client, hass_admin_user): """Test enabling message coalescing.""" await websocket_client.send_json( diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index fd9af99c1a48d3..8f2cd43fdb8b76 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -1,8 +1,11 @@ """Test WebSocket Connection class.""" import asyncio import logging -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock, patch +from aiohttp.test_utils import make_mocked_request +import pytest import voluptuous as vol from homeassistant import exceptions @@ -11,37 +14,86 @@ from tests.common import MockUser -async def test_exception_handling(): - """Test handling of exceptions.""" - send_messages = [] - user = MockUser() - refresh_token = Mock() - conn = websocket_api.ActiveConnection( - logging.getLogger(__name__), None, send_messages.append, user, refresh_token - ) - - for (exc, code, err) in ( - (exceptions.Unauthorized(), websocket_api.ERR_UNAUTHORIZED, "Unauthorized"), +@pytest.mark.parametrize( + "exc,code,err,log", + [ + ( + exceptions.Unauthorized(), + websocket_api.ERR_UNAUTHORIZED, + "Unauthorized", + "Error handling message: Unauthorized (unauthorized) from 127.0.0.42 (Browser)", + ), ( vol.Invalid("Invalid something"), websocket_api.ERR_INVALID_FORMAT, "Invalid something. Got {'id': 5}", + "Error handling message: Invalid something. Got {'id': 5} (invalid_format) from 127.0.0.42 (Browser)", + ), + ( + asyncio.TimeoutError(), + websocket_api.ERR_TIMEOUT, + "Timeout", + "Error handling message: Timeout (timeout) from 127.0.0.42 (Browser)", ), - (asyncio.TimeoutError(), websocket_api.ERR_TIMEOUT, "Timeout"), ( exceptions.HomeAssistantError("Failed to do X"), websocket_api.ERR_UNKNOWN_ERROR, "Failed to do X", + "Error handling message: Failed to do X (unknown_error) from 127.0.0.42 (Browser)", + ), + ( + ValueError("Really bad"), + websocket_api.ERR_UNKNOWN_ERROR, + "Unknown error", + "Error handling message: Unknown error (unknown_error) from 127.0.0.42 (Browser)", ), - (ValueError("Really bad"), websocket_api.ERR_UNKNOWN_ERROR, "Unknown error"), ( - exceptions.HomeAssistantError(), + exceptions.HomeAssistantError, websocket_api.ERR_UNKNOWN_ERROR, "Unknown error", + "Error handling message: Unknown error (unknown_error) from 127.0.0.42 (Browser)", ), - ): - send_messages.clear() + ], +) +async def test_exception_handling( + caplog: pytest.LogCaptureFixture, + exc: Exception, + code: str, + err: str, + log: str, +): + """Test handling of exceptions.""" + send_messages = [] + user = MockUser() + refresh_token = Mock() + current_request = AsyncMock() + + def get_extra_info(key: str) -> Any: + if key == "sslcontext": + return True + + if key == "peername": + return ("127.0.0.42", 8123) + + mocked_transport = Mock() + mocked_transport.get_extra_info = get_extra_info + mocked_request = make_mocked_request( + "GET", + "/api/websocket", + headers={"Host": "example.com", "User-Agent": "Browser"}, + transport=mocked_transport, + ) + + with patch( + "homeassistant.components.websocket_api.connection.current_request", + ) as current_request: + current_request.get.return_value = mocked_request + conn = websocket_api.ActiveConnection( + logging.getLogger(__name__), None, send_messages.append, user, refresh_token + ) + conn.async_handle_exception({"id": 5}, exc) - assert len(send_messages) == 1 - assert send_messages[0]["error"]["code"] == code - assert send_messages[0]["error"]["message"] == err + assert len(send_messages) == 1 + assert send_messages[0]["error"]["code"] == code + assert send_messages[0]["error"]["message"] == err + assert log in caplog.text diff --git a/tests/components/xiaomi_ble/__init__.py b/tests/components/xiaomi_ble/__init__.py index 4593e5c01f3e91..ab88cc559b7b49 100644 --- a/tests/components/xiaomi_ble/__init__.py +++ b/tests/components/xiaomi_ble/__init__.py @@ -1,10 +1,11 @@ """Tests for the SensorPush integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", @@ -14,7 +15,7 @@ service_data={}, service_uuids=[], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -30,7 +31,7 @@ }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -46,7 +47,7 @@ }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -62,7 +63,7 @@ }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -78,7 +79,7 @@ }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -94,7 +95,7 @@ }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -115,7 +116,7 @@ def make_advertisement( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Test Device"), + advertisement=generate_advertisement_data(local_name="Test Device"), time=0, connectable=connectable, ) diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index e47a1a1ace5500..9d8a8b391671cf 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN from . import TEST_MAC @@ -67,7 +67,7 @@ @pytest.fixture(name="xiaomi_miio_connect", autouse=True) def xiaomi_miio_connect_fixture(): - """Mock denonavr connection and entry setup.""" + """Mock miio connection and entry setup.""" mock_info = get_mock_info() with patch( @@ -320,6 +320,22 @@ async def test_config_flow_gateway_cloud_login_error(hass): assert result["step_id"] == "cloud" assert result["errors"] == {"base": "cloud_login_error"} + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.login", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_gateway_cloud_no_devices(hass): """Test a failed config flow using cloud with no devices.""" @@ -348,6 +364,22 @@ async def test_config_flow_gateway_cloud_no_devices(hass): assert result["step_id"] == "cloud" assert result["errors"] == {"base": "cloud_no_devices"} + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_gateway_cloud_missing_token(hass): """Test a failed config flow using cloud with a missing token.""" @@ -558,34 +590,6 @@ async def test_config_flow_step_unknown_device(hass): assert result["errors"] == {"base": "unknown_device"} -async def test_import_flow_success(hass): - """Test a successful import form yaml for a device.""" - mock_info = get_mock_info(model=const.MODELS_SWITCH[0]) - - with patch( - "homeassistant.components.xiaomi_miio.device.Device.info", - return_value=mock_info, - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_NAME: TEST_NAME, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME - assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, - const.CONF_CLOUD_USERNAME: None, - const.CONF_CLOUD_PASSWORD: None, - const.CONF_CLOUD_COUNTRY: None, - CONF_HOST: TEST_HOST, - CONF_TOKEN: TEST_TOKEN, - CONF_MODEL: const.MODELS_SWITCH[0], - const.CONF_MAC: TEST_MAC, - } - - async def test_config_flow_step_device_manual_model_error(hass): """Test config flow, device connection error, model None.""" result = await hass.config_entries.flow.async_init( @@ -618,6 +622,18 @@ async def test_config_flow_step_device_manual_model_error(hass): assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_MODEL: TEST_MODEL}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_step_device_manual_model_succes(hass): """Test config flow, device connection error, manual model.""" @@ -724,7 +740,7 @@ async def config_flow_device_success(hass, model_to_test): async def config_flow_generic_roborock(hass): """Test a successful config flow for a generic roborock vacuum.""" - DUMMY_MODEL = "roborock.vacuum.dummy" + dummy_model = "roborock.vacuum.dummy" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -743,7 +759,7 @@ async def config_flow_generic_roborock(hass): assert result["step_id"] == "manual" assert result["errors"] == {} - mock_info = get_mock_info(model=DUMMY_MODEL) + mock_info = get_mock_info(model=dummy_model) with patch( "homeassistant.components.xiaomi_miio.device.Device.info", @@ -755,7 +771,7 @@ async def config_flow_generic_roborock(hass): ) assert result["type"] == "create_entry" - assert result["title"] == DUMMY_MODEL + assert result["title"] == dummy_model assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, @@ -763,7 +779,7 @@ async def config_flow_generic_roborock(hass): const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - CONF_MODEL: DUMMY_MODEL, + CONF_MODEL: dummy_model, const.CONF_MAC: TEST_MAC, } diff --git a/tests/components/yalexs_ble/__init__.py b/tests/components/yalexs_ble/__init__.py index 36002a49f3eed6..200200c0a0bc25 100644 --- a/tests/components/yalexs_ble/__init__.py +++ b/tests/components/yalexs_ble/__init__.py @@ -1,9 +1,10 @@ """Tests for the Yale Access Bluetooth integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="M1012LU", address="AA:BB:CC:DD:EE:FF", @@ -16,7 +17,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -34,7 +35,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -51,7 +52,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -69,7 +70,7 @@ service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index a3fb0cf621168c..b516bfe3843346 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -21,6 +21,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index fad39a052d5e73..f38705c6e9ac51 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -861,14 +861,16 @@ async def _async_test( await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) - ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) + ct = int(PROPERTIES["ct"]) + ct_mired = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) hue = int(PROPERTIES["hue"]) sat = int(PROPERTIES["sat"]) rgb = int(PROPERTIES["rgb"]) rgb_color = ((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF) hs_color = (hue, sat) bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100) - bg_ct = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) + bg_ct = int(PROPERTIES["bg_ct"]) + bg_ct_kelvin = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) bg_hue = int(PROPERTIES["bg_hue"]) bg_sat = int(PROPERTIES["bg_sat"]) bg_rgb = int(PROPERTIES["bg_rgb"]) @@ -911,6 +913,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -918,7 +924,8 @@ async def _async_test( model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (26.812, 34.87), @@ -936,6 +943,10 @@ async def _async_test( "hs_color": (28.401, 100.0), "rgb_color": (255, 120, 0), "xy_color": (0.621, 0.367), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -945,6 +956,7 @@ async def _async_test( "brightness": nl_br, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -960,6 +972,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -989,6 +1005,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1019,6 +1039,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1046,6 +1070,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1072,6 +1100,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1097,6 +1129,12 @@ async def _async_test( { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1104,7 +1142,8 @@ async def _async_test( model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), @@ -1120,6 +1159,12 @@ async def _async_test( nightlight_mode_properties={ "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1127,6 +1172,9 @@ async def _async_test( model_specs["color_temp"]["min"] ), "brightness": nl_br, + "color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -1151,6 +1199,12 @@ async def _async_test( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1158,7 +1212,8 @@ async def _async_test( model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), @@ -1177,6 +1232,12 @@ async def _async_test( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1184,6 +1245,9 @@ async def _async_test( model_specs["color_temp"]["min"] ), "brightness": nl_br, + "color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -1202,10 +1266,15 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, - "color_temp": bg_ct, + "color_temp_kelvin": bg_ct, + "color_temp": bg_ct_kelvin, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (27.001, 19.243), @@ -1224,6 +1293,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1245,6 +1318,10 @@ async def _async_test( { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, diff --git a/tests/components/zamg/__init__.py b/tests/components/zamg/__init__.py new file mode 100644 index 00000000000000..9c6415d7f84803 --- /dev/null +++ b/tests/components/zamg/__init__.py @@ -0,0 +1 @@ +"""Tests for the ZAMG component.""" diff --git a/tests/components/zamg/conftest.py b/tests/components/zamg/conftest.py new file mode 100644 index 00000000000000..62ef191cb48366 --- /dev/null +++ b/tests/components/zamg/conftest.py @@ -0,0 +1,95 @@ +"""Fixtures for Zamg integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import MagicMock, patch + +import pytest +from zamg import ZamgData as ZamgDevice + +from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +TEST_STATION_ID = "11240" +TEST_STATION_NAME = "Graz/Flughafen" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_STATION_ID: TEST_STATION_ID}, + unique_id=TEST_STATION_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.zamg.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_zamg_config_flow( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + with patch( + "homeassistant.components.zamg.sensor.ZamgData", autospec=True + ) as zamg_mock: + zamg = zamg_mock.return_value + zamg.update.return_value = ZamgDevice( + json.loads(load_fixture("zamg/data.json")) + ) + zamg.get_data.return_value = zamg.get_data(TEST_STATION_ID) + yield zamg + + +@pytest.fixture +def mock_zamg(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + + with patch( + "homeassistant.components.zamg.config_flow.ZamgData", autospec=True + ) as zamg_mock: + zamg = zamg_mock.return_value + zamg.update.return_value = {TEST_STATION_ID: {"Name": TEST_STATION_NAME}} + zamg.zamg_stations.return_value = { + TEST_STATION_ID: (46.99305556, 15.43916667, TEST_STATION_NAME), + "11244": (46.8722229, 15.90361118, "BAD GLEICHENBERG"), + } + zamg.closest_station.return_value = TEST_STATION_ID + zamg.get_data.return_value = TEST_STATION_ID + zamg.get_station_name = TEST_STATION_NAME + yield zamg + + +@pytest.fixture +def mock_zamg_stations( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + with patch( + "homeassistant.components.zamg.config_flow.ZamgData.zamg_stations" + ) as zamg_mock: + zamg_mock.return_value = { + "11240": (46.99305556, 15.43916667, "GRAZ-FLUGHAFEN"), + "11244": (46.87222222, 15.90361111, "BAD GLEICHENBERG"), + } + yield zamg_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, +) -> MockConfigEntry: + """Set up the Zamg integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/zamg/fixtures/data.json b/tests/components/zamg/fixtures/data.json new file mode 100644 index 00000000000000..2f0f3329b4402e --- /dev/null +++ b/tests/components/zamg/fixtures/data.json @@ -0,0 +1,6 @@ +{ + "data": { + "station_id": "11240", + "station_name": "Graz/Flughafen" + } +} diff --git a/tests/components/zamg/test_config_flow.py b/tests/components/zamg/test_config_flow.py new file mode 100644 index 00000000000000..dc2eb62f1b957d --- /dev/null +++ b/tests/components/zamg/test_config_flow.py @@ -0,0 +1,194 @@ +"""Tests for the Zamg config flow.""" +from unittest.mock import MagicMock + +from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN, LOGGER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_STATION_ID, TEST_STATION_NAME + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + LOGGER.debug(result) + assert result.get("data_schema") != "" + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + + +async def test_error_update( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test with error of reading from Zamg.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + LOGGER.debug(result) + assert result.get("data_schema") != "" + mock_zamg.update.side_effect = ValueError + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_full_import_flow_implementation( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full import flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID, CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == {CONF_STATION_ID: TEST_STATION_ID} + + +async def test_user_flow_duplicate( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_duplicate( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test import flow with duplicate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID, CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_duplicate_after_position( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test import flow with duplicate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: "123", CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_no_name( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full import flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == {CONF_STATION_ID: TEST_STATION_ID} diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0de9929fcf82a1..039672b9955a19 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -13,12 +13,13 @@ _get_announced_addresses, ) from homeassistant.const import ( + EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.generated import zeroconf as zc_gen -from homeassistant.setup import async_setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1159,3 +1160,32 @@ async def test_no_name(hass, mock_async_zeroconf): register_call = mock_async_zeroconf.async_register_service.mock_calls[-1] info = register_call.args[0] assert info.name == "Home._home-assistant._tcp.local." + + +async def test_setup_with_disallowed_characters_in_local_name( + hass, mock_async_zeroconf, caplog +): + """Test we still setup with disallowed characters in the location name.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch.object( + hass.config, + "location_name", + "My.House", + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + calls = mock_async_zeroconf.async_register_service.mock_calls + assert calls[0][1][0].name == "My House._home-assistant._tcp.local." + + +async def test_start_with_frontend(hass, mock_async_zeroconf): + """Test we start with the frontend.""" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: "frontend"}) + await hass.async_block_till_done() + + mock_async_zeroconf.async_register_service.assert_called_once() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index e745856c34200a..584abbaecdbc88 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -125,28 +125,28 @@ async def test_get_actions(hass, device_ias): "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaulttoneselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_siren_tone", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultsirenlevelselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_siren_level", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultstrobelevelselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_strobe_level", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultstrobeselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_strobe", "metadata": {"secondary": True}, }, ] @@ -183,7 +183,7 @@ async def test_get_inovelli_actions(hass, device_inovelli): { "device_id": inovelli_reg_device.id, "domain": Platform.BUTTON, - "entity_id": "button.inovelli_vzm31_sn_identifybutton", + "entity_id": "button.inovelli_vzm31_sn_identify", "metadata": {"secondary": True}, "type": "press", }, diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py new file mode 100644 index 00000000000000..64f8c732ca98ef --- /dev/null +++ b/tests/components/zha/test_helpers.py @@ -0,0 +1,211 @@ +"""Tests for ZHA helpers.""" +import logging +from unittest.mock import patch + +import pytest +import voluptuous_serialize +import zigpy.profiles.zha as zha +from zigpy.types.basic import uint16_t +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.lighting as lighting + +from homeassistant.components.zha.core.helpers import ( + cluster_command_schema_to_vol_schema, + convert_to_zcl_values, +) +from homeassistant.const import Platform +import homeassistant.helpers.config_validation as cv + +from .common import async_enable_traffic +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + +@pytest.fixture +async def device_light(hass, zigpy_device_mock, zha_device_joined): + """Test light.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + } + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return color_cluster, zha_device + + +async def test_zcl_schema_conversions(hass, device_light): + """Test ZHA ZCL schema conversion helpers.""" + color_cluster, zha_device = device_light + await async_enable_traffic(hass, [zha_device]) + command_schema = color_cluster.commands_by_name["color_loop_set"].schema + expected_schema = [ + { + "type": "multi_select", + "options": ["Action", "Direction", "Time", "Start Hue"], + "name": "update_flags", + "required": True, + }, + { + "type": "select", + "options": [ + ("Deactivate", "Deactivate"), + ("Activate from color loop hue", "Activate from color loop hue"), + ("Activate from current hue", "Activate from current hue"), + ], + "name": "action", + "required": True, + }, + { + "type": "select", + "options": [("Decrement", "Decrement"), ("Increment", "Increment")], + "name": "direction", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 65535, + "name": "time", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 65535, + "name": "start_hue", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 255, + "name": "options_mask", + "optional": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 255, + "name": "options_override", + "optional": True, + }, + ] + vol_schema = voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(command_schema), + custom_serializer=cv.custom_serializer, + ) + assert vol_schema == expected_schema + + raw_data = { + "update_flags": ["Action", "Start Hue"], + "action": "Activate from current hue", + "direction": "Increment", + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + assert isinstance( + converted_data["update_flags"], lighting.Color.ColorLoopUpdateFlags + ) + assert lighting.Color.ColorLoopUpdateFlags.Action in converted_data["update_flags"] + assert ( + lighting.Color.ColorLoopUpdateFlags.Start_Hue in converted_data["update_flags"] + ) + + assert isinstance(converted_data["action"], lighting.Color.ColorLoopAction) + assert ( + converted_data["action"] + == lighting.Color.ColorLoopAction.Activate_from_current_hue + ) + + assert isinstance(converted_data["direction"], lighting.Color.ColorLoopDirection) + assert converted_data["direction"] == lighting.Color.ColorLoopDirection.Increment + + assert isinstance(converted_data["time"], uint16_t) + assert converted_data["time"] == 20 + + assert isinstance(converted_data["start_hue"], uint16_t) + assert converted_data["start_hue"] == 196 + + raw_data = { + "update_flags": [0b0000_0001, 0b0000_1000], + "action": 0x02, + "direction": 0x01, + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + assert isinstance( + converted_data["update_flags"], lighting.Color.ColorLoopUpdateFlags + ) + assert lighting.Color.ColorLoopUpdateFlags.Action in converted_data["update_flags"] + assert ( + lighting.Color.ColorLoopUpdateFlags.Start_Hue in converted_data["update_flags"] + ) + + assert isinstance(converted_data["action"], lighting.Color.ColorLoopAction) + assert ( + converted_data["action"] + == lighting.Color.ColorLoopAction.Activate_from_current_hue + ) + + assert isinstance(converted_data["direction"], lighting.Color.ColorLoopDirection) + assert converted_data["direction"] == lighting.Color.ColorLoopDirection.Increment + + assert isinstance(converted_data["time"], uint16_t) + assert converted_data["time"] == 20 + + assert isinstance(converted_data["start_hue"], uint16_t) + assert converted_data["start_hue"] == 196 + + # This time, the update flags bitmap is empty + raw_data = { + "update_flags": [], + "action": 0x02, + "direction": 0x01, + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + # No flags are passed through + assert converted_data["update_flags"] == 0 diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 0bb620e98f49aa..219c77f76d7fa3 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -5,6 +5,7 @@ from zigpy.exceptions import ZigbeeException from zigpy.profiles import zha import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.foundation as zcl_f from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN @@ -64,12 +65,13 @@ async def light(zigpy_device_mock): { 1: { SIG_EP_PROFILE: zha.PROFILE_ID, - SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.OnOff.cluster_id, general.LevelControl.cluster_id, + lighting.Color.cluster_id, ], SIG_EP_OUTPUT: [general.Ota.cluster_id], } @@ -211,7 +213,7 @@ async def test_level_control_number( Platform.NUMBER, zha_device, hass, - qualifier=attr.replace("_", ""), + qualifier=attr, ) assert entity_id is not None @@ -322,3 +324,113 @@ async def test_level_control_number( attr: new_value, } assert hass.states.get(entity_id).state == str(initial_value) + + +@pytest.mark.parametrize( + "attr, initial_value, new_value", + (("start_up_color_temperature", 500, 350),), +) +async def test_color_number( + hass, light, zha_device_joined, attr, initial_value, new_value +): + """Test zha color number entities - new join.""" + + entity_registry = er.async_get(hass) + color_cluster = light.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + attr: initial_value, + } + zha_device = await zha_device_joined(light) + + entity_id = await find_entity_id( + Platform.NUMBER, + zha_device, + hass, + qualifier=attr, + ) + assert entity_id is not None + + assert color_cluster.read_attributes.call_count == 3 + assert ( + call( + [ + "color_temp_physical_min", + "color_temp_physical_max", + "color_capabilities", + "start_up_color_temperature", + ], + allow_cache=True, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == str(initial_value) + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + + # Test number set_value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) + + assert color_cluster.write_attributes.call_count == 1 + assert color_cluster.write_attributes.call_args[0][0] == { + attr: new_value, + } + + state = hass.states.get(entity_id) + assert state + assert state.state == str(new_value) + + color_cluster.read_attributes.reset_mock() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + # the mocking doesn't update the attr cache so this flips back to initial value + assert hass.states.get(entity_id).state == str(initial_value) + assert color_cluster.read_attributes.call_count == 1 + assert ( + call( + [ + attr, + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + color_cluster.write_attributes.reset_mock() + color_cluster.write_attributes.side_effect = ZigbeeException + + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) + + assert color_cluster.write_attributes.call_count == 1 + assert color_cluster.write_attributes.call_args[0][0] == { + attr: new_value, + } + assert hass.states.get(entity_id).state == str(initial_value) diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index b9c7297582366b..e9a7f476efb9cc 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -163,7 +163,7 @@ async def test_select_restore_state( ): """Test zha select entity restore state.""" - entity_id = "select.fakemanufacturer_fakemodel_defaulttoneselect" + entity_id = "select.fakemanufacturer_fakemodel_default_siren_tone" core_rs(entity_id, state="Burglar") zigpy_device = zigpy_device_mock( @@ -202,12 +202,12 @@ async def test_on_off_select_new_join(hass, light, zha_device_joined): "start_up_on_off": general.OnOff.StartUpOnOff.On } zha_device = await zha_device_joined(light) - select_name = general.OnOff.StartUpOnOff.__name__ + select_name = "start_up_behavior" entity_id = await find_entity_id( Platform.SELECT, zha_device, hass, - qualifier=select_name.lower(), + qualifier=select_name, ) assert entity_id is not None @@ -285,12 +285,12 @@ async def test_on_off_select_restored(hass, light, zha_device_restored): in on_off_cluster.read_attributes.call_args_list ) - select_name = general.OnOff.StartUpOnOff.__name__ + select_name = "start_up_behavior" entity_id = await find_entity_id( Platform.SELECT, zha_device, hass, - qualifier=select_name.lower(), + qualifier=select_name, ) assert entity_id is not None diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 0698c07db9e5ed..55ea9833caaeef 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -309,7 +309,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( smartenergy.Metering.cluster_id, - "smartenergy_metering", + "instantaneous_demand", async_test_metering, 1, { @@ -323,7 +323,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( smartenergy.Metering.cluster_id, - "smartenergy_summation", + "summation_delivered", async_test_smart_energy_summation, 1, { @@ -339,7 +339,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement", + "active_power", async_test_electrical_measurement, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -347,7 +347,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_apparent_power", + "apparent_power", async_test_em_apparent_power, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -355,7 +355,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_rms_current", + "rms_current", async_test_em_rms_current, 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, @@ -363,7 +363,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_rms_voltage", + "rms_voltage", async_test_em_rms_voltage, 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, @@ -437,7 +437,7 @@ async def test_sensor( zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster.PLUGGED_ATTR_READS = read_plug zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix.replace("_", "")) + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() @@ -642,37 +642,37 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_voltage", "rms_current"}, { - "electrical_measurement", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "active_power", + "ac_frequency", + "power_factor", }, { - "electrical_measurement_apparent_power", - "electrical_measurement_rms_voltage", - "electrical_measurement_rms_current", + "apparent_power", + "rms_voltage", + "rms_current", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_current", "ac_frequency", "power_factor"}, - {"electrical_measurement_rms_voltage", "electrical_measurement"}, + {"rms_voltage", "active_power"}, { - "electrical_measurement_apparent_power", - "electrical_measurement_rms_current", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "apparent_power", + "rms_current", + "ac_frequency", + "power_factor", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, set(), { - "electrical_measurement_rms_voltage", - "electrical_measurement", - "electrical_measurement_apparent_power", - "electrical_measurement_rms_current", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "rms_voltage", + "active_power", + "apparent_power", + "rms_current", + "ac_frequency", + "power_factor", }, set(), ), @@ -682,10 +682,10 @@ async def test_electrical_measurement_init( "instantaneous_demand", }, { - "smartenergy_summation", + "summation_delivered", }, { - "smartenergy_metering", + "instantaneous_demand", }, ), ( @@ -693,16 +693,16 @@ async def test_electrical_measurement_init( {"instantaneous_demand", "current_summ_delivered"}, {}, { - "smartenergy_summation", - "smartenergy_metering", + "summation_delivered", + "instantaneous_demand", }, ), ( smartenergy.Metering.cluster_id, {}, { - "smartenergy_summation", - "smartenergy_metering", + "summation_delivered", + "instantaneous_demand", }, {}, ), @@ -719,10 +719,8 @@ async def test_unsupported_attributes_sensor( ): """Test zha sensor platform.""" - entity_ids = {ENTITY_ID_PREFIX.format(e.replace("_", "")) for e in entity_ids} - missing_entity_ids = { - ENTITY_ID_PREFIX.format(e.replace("_", "")) for e in missing_entity_ids - } + entity_ids = {ENTITY_ID_PREFIX.format(e) for e in entity_ids} + missing_entity_ids = {ENTITY_ID_PREFIX.format(e) for e in missing_entity_ids} zigpy_device = zigpy_device_mock( { @@ -836,7 +834,7 @@ async def test_se_summation_uom( ): """Test zha smart energy summation.""" - entity_id = ENTITY_ID_PREFIX.format("smartenergysummation") + entity_id = ENTITY_ID_PREFIX.format("summation_delivered") zigpy_device = zigpy_device_mock( { 1: { @@ -890,7 +888,7 @@ async def test_elec_measurement_sensor_type( ): """Test zha electrical measurement sensor type.""" - entity_id = ENTITY_ID_PREFIX.format("electricalmeasurement") + entity_id = ENTITY_ID_PREFIX.format("active_power") zigpy_dev = elec_measurement_zigpy_dev zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ "measurement_type" @@ -939,7 +937,7 @@ async def test_elec_measurement_skip_unsupported_attribute( ): """Test zha electrical measurement skipping update of unsupported attributes.""" - entity_id = ENTITY_ID_PREFIX.format("electricalmeasurement") + entity_id = ENTITY_ID_PREFIX.format("active_power") zha_dev = elec_measurement_zha_dev cluster = zha_dev.device.endpoints[1].electrical_measurement diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 2d15f9335dbcec..caa3da9ceef0f2 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -38,7 +38,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], DEV_SIG_ENTITIES: [ - "button.adurolight_adurolight_ncc_identifybutton", + "button.adurolight_adurolight_ncc_identify", "sensor.adurolight_adurolight_ncc_rssi", "sensor.adurolight_adurolight_ncc_lqi", ], @@ -46,7 +46,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -76,7 +76,7 @@ }, DEV_SIG_EVT_CHANNELS: ["5:0x0019"], DEV_SIG_ENTITIES: [ - "button.bosch_isw_zpr1_wp13_identifybutton", + "button.bosch_isw_zpr1_wp13_identify", "sensor.bosch_isw_zpr1_wp13_battery", "sensor.bosch_isw_zpr1_wp13_temperature", "binary_sensor.bosch_isw_zpr1_wp13_iaszone", @@ -92,7 +92,7 @@ ("button", "00:11:22:33:44:55:66:77-5-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-5-1"): { DEV_SIG_CHANNELS: ["power"], @@ -132,7 +132,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3130_identifybutton", + "button.centralite_3130_identify", "sensor.centralite_3130_battery", "sensor.centralite_3130_rssi", "sensor.centralite_3130_lqi", @@ -141,7 +141,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -176,15 +176,15 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3210_l_identifybutton", - "sensor.centralite_3210_l_electricalmeasurement", - "sensor.centralite_3210_l_electricalmeasurementapparentpower", - "sensor.centralite_3210_l_electricalmeasurementrmscurrent", - "sensor.centralite_3210_l_electricalmeasurementrmsvoltage", - "sensor.centralite_3210_l_electricalmeasurementfrequency", - "sensor.centralite_3210_l_electricalmeasurementpowerfactor", - "sensor.centralite_3210_l_smartenergymetering", - "sensor.centralite_3210_l_smartenergysummation", + "button.centralite_3210_l_identify", + "sensor.centralite_3210_l_active_power", + "sensor.centralite_3210_l_apparent_power", + "sensor.centralite_3210_l_rms_current", + "sensor.centralite_3210_l_rms_voltage", + "sensor.centralite_3210_l_ac_frequency", + "sensor.centralite_3210_l_power_factor", + "sensor.centralite_3210_l_instantaneous_demand", + "sensor.centralite_3210_l_summation_delivered", "switch.centralite_3210_l_switch", "sensor.centralite_3210_l_rssi", "sensor.centralite_3210_l_lqi", @@ -198,47 +198,47 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -268,7 +268,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3310_s_identifybutton", + "button.centralite_3310_s_identify", "sensor.centralite_3310_s_battery", "sensor.centralite_3310_s_temperature", "sensor.centralite_3310_s_humidity", @@ -279,7 +279,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -331,7 +331,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3315_s_identifybutton", + "button.centralite_3315_s_identify", "sensor.centralite_3315_s_battery", "sensor.centralite_3315_s_temperature", "binary_sensor.centralite_3315_s_iaszone", @@ -347,7 +347,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -394,7 +394,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3320_l_identifybutton", + "button.centralite_3320_l_identify", "sensor.centralite_3320_l_battery", "sensor.centralite_3320_l_temperature", "binary_sensor.centralite_3320_l_iaszone", @@ -410,7 +410,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -457,7 +457,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3326_l_identifybutton", + "button.centralite_3326_l_identify", "sensor.centralite_3326_l_battery", "sensor.centralite_3326_l_temperature", "binary_sensor.centralite_3326_l_iaszone", @@ -473,7 +473,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -520,7 +520,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_motion_sensor_a_identifybutton", + "button.centralite_motion_sensor_a_identify", "sensor.centralite_motion_sensor_a_battery", "sensor.centralite_motion_sensor_a_temperature", "binary_sensor.centralite_motion_sensor_a_iaszone", @@ -537,7 +537,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -589,9 +589,9 @@ }, DEV_SIG_EVT_CHANNELS: ["4:0x0019"], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_psmp5_00_00_02_02tc_identifybutton", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergymetering", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergysummation", + "button.climaxtechnology_psmp5_00_00_02_02tc_identify", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_switch", "sensor.climaxtechnology_psmp5_00_00_02_02tc_rssi", "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", @@ -605,17 +605,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_psmp5_00_00_02_02tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_psmp5_00_00_02_02tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -645,14 +645,14 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_sd8sc_00_00_03_12tc_identifybutton", + "button.climaxtechnology_sd8sc_00_00_03_12tc_identify", "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_iaszone", "sensor.climaxtechnology_sd8sc_00_00_03_12tc_rssi", "sensor.climaxtechnology_sd8sc_00_00_03_12tc_lqi", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaulttoneselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultsirenlevelselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobelevelselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobeselect", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe", "siren.climaxtechnology_sd8sc_00_00_03_12tc_siren", ], DEV_SIG_ENT_MAP: { @@ -664,7 +664,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_sd8sc_00_00_03_12tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_sd8sc_00_00_03_12tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -679,22 +679,22 @@ ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -719,7 +719,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_ws15_00_00_03_03tc_identifybutton", + "button.climaxtechnology_ws15_00_00_03_03tc_identify", "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_iaszone", "sensor.climaxtechnology_ws15_00_00_03_03tc_rssi", "sensor.climaxtechnology_ws15_00_00_03_03tc_lqi", @@ -733,7 +733,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_ws15_00_00_03_03tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_ws15_00_00_03_03tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -770,7 +770,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.feibit_inc_co_fb56_zcw08ku1_1_identifybutton", + "button.feibit_inc_co_fb56_zcw08ku1_1_identify", "light.feibit_inc_co_fb56_zcw08ku1_1_light", "sensor.feibit_inc_co_fb56_zcw08ku1_1_rssi", "sensor.feibit_inc_co_fb56_zcw08ku1_1_lqi", @@ -784,7 +784,7 @@ ("button", "00:11:22:33:44:55:66:77-11-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-11-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -814,15 +814,15 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_smokesensor_em_identifybutton", + "button.heiman_smokesensor_em_identify", "sensor.heiman_smokesensor_em_battery", "binary_sensor.heiman_smokesensor_em_iaszone", "sensor.heiman_smokesensor_em_rssi", "sensor.heiman_smokesensor_em_lqi", - "select.heiman_smokesensor_em_defaulttoneselect", - "select.heiman_smokesensor_em_defaultsirenlevelselect", - "select.heiman_smokesensor_em_defaultstrobelevelselect", - "select.heiman_smokesensor_em_defaultstrobeselect", + "select.heiman_smokesensor_em_default_siren_tone", + "select.heiman_smokesensor_em_default_siren_level", + "select.heiman_smokesensor_em_default_strobe_level", + "select.heiman_smokesensor_em_default_strobe", "siren.heiman_smokesensor_em_siren", ], DEV_SIG_ENT_MAP: { @@ -834,7 +834,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -854,22 +854,22 @@ ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -894,7 +894,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_co_v16_identifybutton", + "button.heiman_co_v16_identify", "binary_sensor.heiman_co_v16_iaszone", "sensor.heiman_co_v16_rssi", "sensor.heiman_co_v16_lqi", @@ -908,7 +908,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -938,36 +938,36 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_warningdevice_identifybutton", + "button.heiman_warningdevice_identify", "binary_sensor.heiman_warningdevice_iaszone", "sensor.heiman_warningdevice_rssi", "sensor.heiman_warningdevice_lqi", - "select.heiman_warningdevice_defaulttoneselect", - "select.heiman_warningdevice_defaultsirenlevelselect", - "select.heiman_warningdevice_defaultstrobelevelselect", - "select.heiman_warningdevice_defaultstrobeselect", + "select.heiman_warningdevice_default_siren_tone", + "select.heiman_warningdevice_default_siren_level", + "select.heiman_warningdevice_default_strobe_level", + "select.heiman_warningdevice_default_strobe", "siren.heiman_warningdevice_siren", ], DEV_SIG_ENT_MAP: { ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -982,7 +982,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1012,7 +1012,7 @@ }, DEV_SIG_EVT_CHANNELS: ["6:0x0019"], DEV_SIG_ENTITIES: [ - "button.hivehome_com_mot003_identifybutton", + "button.hivehome_com_mot003_identify", "sensor.hivehome_com_mot003_battery", "sensor.hivehome_com_mot003_illuminance", "sensor.hivehome_com_mot003_temperature", @@ -1029,7 +1029,7 @@ ("button", "00:11:22:33:44:55:66:77-6-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identify", }, ("sensor", "00:11:22:33:44:55:66:77-6-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1081,7 +1081,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify", "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi", @@ -1095,7 +1095,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1125,7 +1125,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi", @@ -1139,7 +1139,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1169,7 +1169,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi", @@ -1183,7 +1183,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1213,7 +1213,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi", @@ -1227,7 +1227,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1257,7 +1257,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi", @@ -1271,7 +1271,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1301,7 +1301,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_control_outlet_identifybutton", + "button.ikea_of_sweden_tradfri_control_outlet_identify", "switch.ikea_of_sweden_tradfri_control_outlet_switch", "sensor.ikea_of_sweden_tradfri_control_outlet_rssi", "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", @@ -1315,7 +1315,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_control_outlet_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_control_outlet_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1345,7 +1345,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_motion_sensor_identifybutton", + "button.ikea_of_sweden_tradfri_motion_sensor_identify", "sensor.ikea_of_sweden_tradfri_motion_sensor_battery", "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion", "sensor.ikea_of_sweden_tradfri_motion_sensor_rssi", @@ -1355,7 +1355,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_motion_sensor_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_motion_sensor_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1395,7 +1395,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_on_off_switch_identifybutton", + "button.ikea_of_sweden_tradfri_on_off_switch_identify", "sensor.ikea_of_sweden_tradfri_on_off_switch_battery", "sensor.ikea_of_sweden_tradfri_on_off_switch_rssi", "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", @@ -1404,7 +1404,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_on_off_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_on_off_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1439,7 +1439,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_remote_control_identifybutton", + "button.ikea_of_sweden_tradfri_remote_control_identify", "sensor.ikea_of_sweden_tradfri_remote_control_battery", "sensor.ikea_of_sweden_tradfri_remote_control_rssi", "sensor.ikea_of_sweden_tradfri_remote_control_lqi", @@ -1448,7 +1448,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_remote_control_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_remote_control_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1490,7 +1490,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_signal_repeater_identifybutton", + "button.ikea_of_sweden_tradfri_signal_repeater_identify", "sensor.ikea_of_sweden_tradfri_signal_repeater_rssi", "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", ], @@ -1498,7 +1498,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_signal_repeater_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_signal_repeater_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1528,7 +1528,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_wireless_dimmer_identifybutton", + "button.ikea_of_sweden_tradfri_wireless_dimmer_identify", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_battery", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_rssi", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", @@ -1537,7 +1537,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_wireless_dimmer_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_wireless_dimmer_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1579,9 +1579,9 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45852_identifybutton", - "sensor.jasco_products_45852_smartenergymetering", - "sensor.jasco_products_45852_smartenergysummation", + "button.jasco_products_45852_identify", + "sensor.jasco_products_45852_instantaneous_demand", + "sensor.jasco_products_45852_summation_delivered", "light.jasco_products_45852_light", "sensor.jasco_products_45852_rssi", "sensor.jasco_products_45852_lqi", @@ -1595,17 +1595,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1642,10 +1642,10 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45856_identifybutton", + "button.jasco_products_45856_identify", "light.jasco_products_45856_light", - "sensor.jasco_products_45856_smartenergymetering", - "sensor.jasco_products_45856_smartenergysummation", + "sensor.jasco_products_45856_instantaneous_demand", + "sensor.jasco_products_45856_summation_delivered", "sensor.jasco_products_45856_rssi", "sensor.jasco_products_45856_lqi", ], @@ -1658,17 +1658,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1705,10 +1705,10 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45857_identifybutton", + "button.jasco_products_45857_identify", "light.jasco_products_45857_light", - "sensor.jasco_products_45857_smartenergymetering", - "sensor.jasco_products_45857_smartenergysummation", + "sensor.jasco_products_45857_instantaneous_demand", + "sensor.jasco_products_45857_summation_delivered", "sensor.jasco_products_45857_rssi", "sensor.jasco_products_45857_lqi", ], @@ -1721,17 +1721,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1761,7 +1761,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_610_mp_1_3_identifybutton", + "button.keen_home_inc_sv02_610_mp_1_3_identify", "sensor.keen_home_inc_sv02_610_mp_1_3_battery", "sensor.keen_home_inc_sv02_610_mp_1_3_pressure", "sensor.keen_home_inc_sv02_610_mp_1_3_temperature", @@ -1773,7 +1773,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1823,7 +1823,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_2_identifybutton", + "button.keen_home_inc_sv02_612_mp_1_2_identify", "sensor.keen_home_inc_sv02_612_mp_1_2_battery", "sensor.keen_home_inc_sv02_612_mp_1_2_pressure", "sensor.keen_home_inc_sv02_612_mp_1_2_temperature", @@ -1835,7 +1835,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1885,7 +1885,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_3_identifybutton", + "button.keen_home_inc_sv02_612_mp_1_3_identify", "sensor.keen_home_inc_sv02_612_mp_1_3_battery", "sensor.keen_home_inc_sv02_612_mp_1_3_pressure", "sensor.keen_home_inc_sv02_612_mp_1_3_temperature", @@ -1897,7 +1897,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1947,7 +1947,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.king_of_fans_inc_hbuniversalcfremote_identifybutton", + "button.king_of_fans_inc_hbuniversalcfremote_identify", "light.king_of_fans_inc_hbuniversalcfremote_light", "fan.king_of_fans_inc_hbuniversalcfremote_fan", "sensor.king_of_fans_inc_hbuniversalcfremote_rssi", @@ -1962,7 +1962,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.king_of_fans_inc_hbuniversalcfremote_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.king_of_fans_inc_hbuniversalcfremote_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1997,7 +1997,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lds_zbt_cctswitch_d0001_identifybutton", + "button.lds_zbt_cctswitch_d0001_identify", "sensor.lds_zbt_cctswitch_d0001_battery", "sensor.lds_zbt_cctswitch_d0001_rssi", "sensor.lds_zbt_cctswitch_d0001_lqi", @@ -2006,7 +2006,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2041,7 +2041,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_a19_rgbw_identifybutton", + "button.ledvance_a19_rgbw_identify", "light.ledvance_a19_rgbw_light", "sensor.ledvance_a19_rgbw_rssi", "sensor.ledvance_a19_rgbw_lqi", @@ -2055,7 +2055,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2085,7 +2085,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_flex_rgbw_identifybutton", + "button.ledvance_flex_rgbw_identify", "light.ledvance_flex_rgbw_light", "sensor.ledvance_flex_rgbw_rssi", "sensor.ledvance_flex_rgbw_lqi", @@ -2099,7 +2099,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2129,7 +2129,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_plug_identifybutton", + "button.ledvance_plug_identify", "switch.ledvance_plug_switch", "sensor.ledvance_plug_rssi", "sensor.ledvance_plug_lqi", @@ -2143,7 +2143,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2173,7 +2173,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_rt_rgbw_identifybutton", + "button.ledvance_rt_rgbw_identify", "light.ledvance_rt_rgbw_light", "sensor.ledvance_rt_rgbw_rssi", "sensor.ledvance_rt_rgbw_lqi", @@ -2187,7 +2187,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2238,20 +2238,20 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_plug_maus01_identifybutton", - "sensor.lumi_lumi_plug_maus01_electricalmeasurement", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementapparentpower", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmscurrent", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmsvoltage", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementfrequency", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementpowerfactor", + "button.lumi_lumi_plug_maus01_identify", + "sensor.lumi_lumi_plug_maus01_active_power", + "sensor.lumi_lumi_plug_maus01_apparent_power", + "sensor.lumi_lumi_plug_maus01_rms_current", + "sensor.lumi_lumi_plug_maus01_rms_voltage", + "sensor.lumi_lumi_plug_maus01_ac_frequency", + "sensor.lumi_lumi_plug_maus01_power_factor", "sensor.lumi_lumi_plug_maus01_analoginput", "sensor.lumi_lumi_plug_maus01_analoginput_2", "binary_sensor.lumi_lumi_plug_maus01_binaryinput", "switch.lumi_lumi_plug_maus01_switch", "sensor.lumi_lumi_plug_maus01_rssi", "sensor.lumi_lumi_plug_maus01_lqi", - "sensor.lumi_lumi_plug_maus01_devicetemperature", + "sensor.lumi_lumi_plug_maus01_device_temperature", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { @@ -2262,42 +2262,42 @@ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2349,18 +2349,18 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_relay_c2acn01_identifybutton", + "button.lumi_lumi_relay_c2acn01_identify", "light.lumi_lumi_relay_c2acn01_light", "light.lumi_lumi_relay_c2acn01_light_2", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurement", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementapparentpower", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmscurrent", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmsvoltage", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementfrequency", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementpowerfactor", + "sensor.lumi_lumi_relay_c2acn01_active_power", + "sensor.lumi_lumi_relay_c2acn01_apparent_power", + "sensor.lumi_lumi_relay_c2acn01_rms_current", + "sensor.lumi_lumi_relay_c2acn01_rms_voltage", + "sensor.lumi_lumi_relay_c2acn01_ac_frequency", + "sensor.lumi_lumi_relay_c2acn01_power_factor", "sensor.lumi_lumi_relay_c2acn01_rssi", "sensor.lumi_lumi_relay_c2acn01_lqi", - "sensor.lumi_lumi_relay_c2acn01_devicetemperature", + "sensor.lumi_lumi_relay_c2acn01_device_temperature", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -2371,42 +2371,42 @@ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2455,7 +2455,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b186acn01_identifybutton", + "button.lumi_lumi_remote_b186acn01_identify", "sensor.lumi_lumi_remote_b186acn01_battery", "sensor.lumi_lumi_remote_b186acn01_rssi", "sensor.lumi_lumi_remote_b186acn01_lqi", @@ -2464,7 +2464,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2513,7 +2513,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286acn01_identifybutton", + "button.lumi_lumi_remote_b286acn01_identify", "sensor.lumi_lumi_remote_b286acn01_battery", "sensor.lumi_lumi_remote_b286acn01_rssi", "sensor.lumi_lumi_remote_b286acn01_lqi", @@ -2522,7 +2522,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2592,7 +2592,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286opcn01_identifybutton", + "button.lumi_lumi_remote_b286opcn01_identify", "sensor.lumi_lumi_remote_b286opcn01_rssi", "sensor.lumi_lumi_remote_b286opcn01_lqi", ], @@ -2600,7 +2600,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2665,7 +2665,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b486opcn01_identifybutton", + "button.lumi_lumi_remote_b486opcn01_identify", "sensor.lumi_lumi_remote_b486opcn01_rssi", "sensor.lumi_lumi_remote_b486opcn01_lqi", ], @@ -2673,7 +2673,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2703,7 +2703,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identifybutton", + "button.lumi_lumi_remote_b686opcn01_identify", "sensor.lumi_lumi_remote_b686opcn01_rssi", "sensor.lumi_lumi_remote_b686opcn01_lqi", ], @@ -2711,7 +2711,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2776,7 +2776,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identifybutton", + "button.lumi_lumi_remote_b686opcn01_identify", "sensor.lumi_lumi_remote_b686opcn01_rssi", "sensor.lumi_lumi_remote_b686opcn01_lqi", ], @@ -2784,7 +2784,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2946,7 +2946,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sen_ill_mgl01_identifybutton", + "button.lumi_lumi_sen_ill_mgl01_identify", "sensor.lumi_lumi_sen_ill_mgl01_illuminance", "sensor.lumi_lumi_sen_ill_mgl01_rssi", "sensor.lumi_lumi_sen_ill_mgl01_lqi", @@ -2955,7 +2955,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { DEV_SIG_CHANNELS: ["illuminance"], @@ -3004,7 +3004,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_86sw1_identifybutton", + "button.lumi_lumi_sensor_86sw1_identify", "sensor.lumi_lumi_sensor_86sw1_battery", "sensor.lumi_lumi_sensor_86sw1_rssi", "sensor.lumi_lumi_sensor_86sw1_lqi", @@ -3013,7 +3013,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3062,7 +3062,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_cube_aqgl01_identifybutton", + "button.lumi_lumi_sensor_cube_aqgl01_identify", "sensor.lumi_lumi_sensor_cube_aqgl01_battery", "sensor.lumi_lumi_sensor_cube_aqgl01_rssi", "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", @@ -3071,7 +3071,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3120,7 +3120,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_ht_identifybutton", + "button.lumi_lumi_sensor_ht_identify", "sensor.lumi_lumi_sensor_ht_battery", "sensor.lumi_lumi_sensor_ht_temperature", "sensor.lumi_lumi_sensor_ht_humidity", @@ -3131,7 +3131,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3176,7 +3176,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_identifybutton", + "button.lumi_lumi_sensor_magnet_identify", "sensor.lumi_lumi_sensor_magnet_battery", "binary_sensor.lumi_lumi_sensor_magnet_opening", "sensor.lumi_lumi_sensor_magnet_rssi", @@ -3186,7 +3186,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3226,7 +3226,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_aq2_identifybutton", + "button.lumi_lumi_sensor_magnet_aq2_identify", "sensor.lumi_lumi_sensor_magnet_aq2_battery", "binary_sensor.lumi_lumi_sensor_magnet_aq2_opening", "sensor.lumi_lumi_sensor_magnet_aq2_rssi", @@ -3236,7 +3236,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3276,7 +3276,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_motion_aq2_identifybutton", + "button.lumi_lumi_sensor_motion_aq2_identify", "sensor.lumi_lumi_sensor_motion_aq2_battery", "sensor.lumi_lumi_sensor_motion_aq2_illuminance", "binary_sensor.lumi_lumi_sensor_motion_aq2_occupancy", @@ -3298,7 +3298,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3338,7 +3338,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_smoke_identifybutton", + "button.lumi_lumi_sensor_smoke_identify", "sensor.lumi_lumi_sensor_smoke_battery", "binary_sensor.lumi_lumi_sensor_smoke_iaszone", "sensor.lumi_lumi_sensor_smoke_rssi", @@ -3353,7 +3353,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3388,7 +3388,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_switch_identifybutton", + "button.lumi_lumi_sensor_switch_identify", "sensor.lumi_lumi_sensor_switch_battery", "sensor.lumi_lumi_sensor_switch_rssi", "sensor.lumi_lumi_sensor_switch_lqi", @@ -3397,7 +3397,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3508,12 +3508,12 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_wleak_aq1_identifybutton", + "button.lumi_lumi_sensor_wleak_aq1_identify", "sensor.lumi_lumi_sensor_wleak_aq1_battery", "binary_sensor.lumi_lumi_sensor_wleak_aq1_iaszone", "sensor.lumi_lumi_sensor_wleak_aq1_rssi", "sensor.lumi_lumi_sensor_wleak_aq1_lqi", - "sensor.lumi_lumi_sensor_wleak_aq1_devicetemperature", + "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature", ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { @@ -3524,12 +3524,12 @@ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3571,7 +3571,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_vibration_aq1_identifybutton", + "button.lumi_lumi_vibration_aq1_identify", "sensor.lumi_lumi_vibration_aq1_battery", "binary_sensor.lumi_lumi_vibration_aq1_iaszone", "lock.lumi_lumi_vibration_aq1_doorlock", @@ -3587,7 +3587,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3627,7 +3627,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_weather_identifybutton", + "button.lumi_lumi_weather_identify", "sensor.lumi_lumi_weather_battery", "sensor.lumi_lumi_weather_pressure", "sensor.lumi_lumi_weather_temperature", @@ -3639,7 +3639,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3689,7 +3689,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.nyce_3010_identifybutton", + "button.nyce_3010_identify", "sensor.nyce_3010_battery", "binary_sensor.nyce_3010_iaszone", "sensor.nyce_3010_rssi", @@ -3704,7 +3704,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3739,7 +3739,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.nyce_3014_identifybutton", + "button.nyce_3014_identify", "sensor.nyce_3014_battery", "binary_sensor.nyce_3014_iaszone", "sensor.nyce_3014_rssi", @@ -3754,7 +3754,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3832,7 +3832,7 @@ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_a19_rgbw_identifybutton", + "button.osram_lightify_a19_rgbw_identify", "light.osram_lightify_a19_rgbw_light", "sensor.osram_lightify_a19_rgbw_rssi", "sensor.osram_lightify_a19_rgbw_lqi", @@ -3846,7 +3846,7 @@ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -3876,7 +3876,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_dimming_switch_identifybutton", + "button.osram_lightify_dimming_switch_identify", "sensor.osram_lightify_dimming_switch_battery", "sensor.osram_lightify_dimming_switch_rssi", "sensor.osram_lightify_dimming_switch_lqi", @@ -3885,7 +3885,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3920,7 +3920,7 @@ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_flex_rgbw_identifybutton", + "button.osram_lightify_flex_rgbw_identify", "light.osram_lightify_flex_rgbw_light", "sensor.osram_lightify_flex_rgbw_rssi", "sensor.osram_lightify_flex_rgbw_lqi", @@ -3934,7 +3934,7 @@ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -3964,14 +3964,14 @@ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_rt_tunable_white_identifybutton", + "button.osram_lightify_rt_tunable_white_identify", "light.osram_lightify_rt_tunable_white_light", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurement", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementapparentpower", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmscurrent", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmsvoltage", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementfrequency", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementpowerfactor", + "sensor.osram_lightify_rt_tunable_white_active_power", + "sensor.osram_lightify_rt_tunable_white_apparent_power", + "sensor.osram_lightify_rt_tunable_white_rms_current", + "sensor.osram_lightify_rt_tunable_white_rms_voltage", + "sensor.osram_lightify_rt_tunable_white_ac_frequency", + "sensor.osram_lightify_rt_tunable_white_power_factor", "sensor.osram_lightify_rt_tunable_white_rssi", "sensor.osram_lightify_rt_tunable_white_lqi", ], @@ -3984,37 +3984,37 @@ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4044,13 +4044,13 @@ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_plug_01_identifybutton", - "sensor.osram_plug_01_electricalmeasurement", - "sensor.osram_plug_01_electricalmeasurementapparentpower", - "sensor.osram_plug_01_electricalmeasurementrmscurrent", - "sensor.osram_plug_01_electricalmeasurementrmsvoltage", - "sensor.osram_plug_01_electricalmeasurementfrequency", - "sensor.osram_plug_01_electricalmeasurementpowerfactor", + "button.osram_plug_01_identify", + "sensor.osram_plug_01_active_power", + "sensor.osram_plug_01_apparent_power", + "sensor.osram_plug_01_rms_current", + "sensor.osram_plug_01_rms_voltage", + "sensor.osram_plug_01_ac_frequency", + "sensor.osram_plug_01_power_factor", "switch.osram_plug_01_switch", "sensor.osram_plug_01_rssi", "sensor.osram_plug_01_lqi", @@ -4064,37 +4064,37 @@ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4230,7 +4230,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], DEV_SIG_ENTITIES: [ - "button.philips_rwl020_identifybutton", + "button.philips_rwl020_identify", "sensor.philips_rwl020_battery", "binary_sensor.philips_rwl020_binaryinput", "sensor.philips_rwl020_rssi", @@ -4255,7 +4255,7 @@ ("button", "00:11:22:33:44:55:66:77-2-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identify", }, ("sensor", "00:11:22:33:44:55:66:77-2-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4280,7 +4280,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_button_identifybutton", + "button.samjin_button_identify", "sensor.samjin_button_battery", "sensor.samjin_button_temperature", "binary_sensor.samjin_button_iaszone", @@ -4296,7 +4296,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_button_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_button_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4336,7 +4336,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_multi_identifybutton", + "button.samjin_multi_identify", "sensor.samjin_multi_battery", "sensor.samjin_multi_temperature", "binary_sensor.samjin_multi_iaszone", @@ -4352,7 +4352,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4392,7 +4392,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_water_identifybutton", + "button.samjin_water_identify", "sensor.samjin_water_battery", "sensor.samjin_water_temperature", "binary_sensor.samjin_water_iaszone", @@ -4408,7 +4408,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_water_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_water_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4448,13 +4448,13 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.securifi_ltd_unk_model_identifybutton", - "sensor.securifi_ltd_unk_model_electricalmeasurement", - "sensor.securifi_ltd_unk_model_electricalmeasurementapparentpower", - "sensor.securifi_ltd_unk_model_electricalmeasurementrmscurrent", - "sensor.securifi_ltd_unk_model_electricalmeasurementrmsvoltage", - "sensor.securifi_ltd_unk_model_electricalmeasurementfrequency", - "sensor.securifi_ltd_unk_model_electricalmeasurementpowerfactor", + "button.securifi_ltd_unk_model_identify", + "sensor.securifi_ltd_unk_model_active_power", + "sensor.securifi_ltd_unk_model_apparent_power", + "sensor.securifi_ltd_unk_model_rms_current", + "sensor.securifi_ltd_unk_model_rms_voltage", + "sensor.securifi_ltd_unk_model_ac_frequency", + "sensor.securifi_ltd_unk_model_power_factor", "switch.securifi_ltd_unk_model_switch", "sensor.securifi_ltd_unk_model_rssi", "sensor.securifi_ltd_unk_model_lqi", @@ -4463,37 +4463,37 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4528,7 +4528,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_dws04n_sf_identifybutton", + "button.sercomm_corp_sz_dws04n_sf_identify", "sensor.sercomm_corp_sz_dws04n_sf_battery", "sensor.sercomm_corp_sz_dws04n_sf_temperature", "binary_sensor.sercomm_corp_sz_dws04n_sf_iaszone", @@ -4544,7 +4544,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4591,15 +4591,15 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_esw01_identifybutton", - "sensor.sercomm_corp_sz_esw01_electricalmeasurement", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementapparentpower", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmscurrent", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmsvoltage", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementfrequency", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementpowerfactor", - "sensor.sercomm_corp_sz_esw01_smartenergymetering", - "sensor.sercomm_corp_sz_esw01_smartenergysummation", + "button.sercomm_corp_sz_esw01_identify", + "sensor.sercomm_corp_sz_esw01_active_power", + "sensor.sercomm_corp_sz_esw01_apparent_power", + "sensor.sercomm_corp_sz_esw01_rms_current", + "sensor.sercomm_corp_sz_esw01_rms_voltage", + "sensor.sercomm_corp_sz_esw01_ac_frequency", + "sensor.sercomm_corp_sz_esw01_power_factor", + "sensor.sercomm_corp_sz_esw01_instantaneous_demand", + "sensor.sercomm_corp_sz_esw01_summation_delivered", "light.sercomm_corp_sz_esw01_light", "sensor.sercomm_corp_sz_esw01_rssi", "sensor.sercomm_corp_sz_esw01_lqi", @@ -4613,47 +4613,47 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4683,7 +4683,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_pir04_identifybutton", + "button.sercomm_corp_sz_pir04_identify", "sensor.sercomm_corp_sz_pir04_battery", "sensor.sercomm_corp_sz_pir04_illuminance", "sensor.sercomm_corp_sz_pir04_temperature", @@ -4700,7 +4700,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4745,13 +4745,13 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_rm3250zb_identifybutton", - "sensor.sinope_technologies_rm3250zb_electricalmeasurement", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_rm3250zb_identify", + "sensor.sinope_technologies_rm3250zb_active_power", + "sensor.sinope_technologies_rm3250zb_apparent_power", + "sensor.sinope_technologies_rm3250zb_rms_current", + "sensor.sinope_technologies_rm3250zb_rms_voltage", + "sensor.sinope_technologies_rm3250zb_ac_frequency", + "sensor.sinope_technologies_rm3250zb_power_factor", "switch.sinope_technologies_rm3250zb_switch", "sensor.sinope_technologies_rm3250zb_rssi", "sensor.sinope_technologies_rm3250zb_lqi", @@ -4760,37 +4760,37 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4832,15 +4832,15 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1123zb_identifybutton", - "sensor.sinope_technologies_th1123zb_electricalmeasurement", - "sensor.sinope_technologies_th1123zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_th1123zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_th1123zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_th1123zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_th1123zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_th1123zb_identify", + "sensor.sinope_technologies_th1123zb_active_power", + "sensor.sinope_technologies_th1123zb_apparent_power", + "sensor.sinope_technologies_th1123zb_rms_current", + "sensor.sinope_technologies_th1123zb_rms_voltage", + "sensor.sinope_technologies_th1123zb_ac_frequency", + "sensor.sinope_technologies_th1123zb_power_factor", "sensor.sinope_technologies_th1123zb_temperature", - "sensor.sinope_technologies_th1123zb_sinopehvacaction", + "sensor.sinope_technologies_th1123zb_hvac_action", "climate.sinope_technologies_th1123zb_thermostat", "sensor.sinope_technologies_th1123zb_rssi", "sensor.sinope_technologies_th1123zb_lqi", @@ -4849,7 +4849,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], @@ -4859,32 +4859,32 @@ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], @@ -4904,7 +4904,7 @@ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_sinopehvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", }, }, }, @@ -4931,15 +4931,15 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1124zb_identifybutton", - "sensor.sinope_technologies_th1124zb_electricalmeasurement", - "sensor.sinope_technologies_th1124zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_th1124zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_th1124zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_th1124zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_th1124zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_th1124zb_identify", + "sensor.sinope_technologies_th1124zb_active_power", + "sensor.sinope_technologies_th1124zb_apparent_power", + "sensor.sinope_technologies_th1124zb_rms_current", + "sensor.sinope_technologies_th1124zb_rms_voltage", + "sensor.sinope_technologies_th1124zb_ac_frequency", + "sensor.sinope_technologies_th1124zb_power_factor", "sensor.sinope_technologies_th1124zb_temperature", - "sensor.sinope_technologies_th1124zb_sinopehvacaction", + "sensor.sinope_technologies_th1124zb_hvac_action", "climate.sinope_technologies_th1124zb_thermostat", "sensor.sinope_technologies_th1124zb_rssi", "sensor.sinope_technologies_th1124zb_lqi", @@ -4948,7 +4948,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], @@ -4958,32 +4958,32 @@ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], @@ -5003,7 +5003,7 @@ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_sinopehvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", }, }, }, @@ -5023,13 +5023,13 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.smartthings_outletv4_identifybutton", - "sensor.smartthings_outletv4_electricalmeasurement", - "sensor.smartthings_outletv4_electricalmeasurementapparentpower", - "sensor.smartthings_outletv4_electricalmeasurementrmscurrent", - "sensor.smartthings_outletv4_electricalmeasurementrmsvoltage", - "sensor.smartthings_outletv4_electricalmeasurementfrequency", - "sensor.smartthings_outletv4_electricalmeasurementpowerfactor", + "button.smartthings_outletv4_identify", + "sensor.smartthings_outletv4_active_power", + "sensor.smartthings_outletv4_apparent_power", + "sensor.smartthings_outletv4_rms_current", + "sensor.smartthings_outletv4_rms_voltage", + "sensor.smartthings_outletv4_ac_frequency", + "sensor.smartthings_outletv4_power_factor", "binary_sensor.smartthings_outletv4_binaryinput", "switch.smartthings_outletv4_switch", "sensor.smartthings_outletv4_rssi", @@ -5044,37 +5044,37 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5109,7 +5109,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.smartthings_tagv4_identifybutton", + "button.smartthings_tagv4_identify", "device_tracker.smartthings_tagv4_devicescanner", "binary_sensor.smartthings_tagv4_binaryinput", "sensor.smartthings_tagv4_rssi", @@ -5129,7 +5129,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5159,7 +5159,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss007z_identifybutton", + "button.third_reality_inc_3rss007z_identify", "switch.third_reality_inc_3rss007z_switch", "sensor.third_reality_inc_3rss007z_rssi", "sensor.third_reality_inc_3rss007z_lqi", @@ -5168,7 +5168,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5203,7 +5203,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss008z_identifybutton", + "button.third_reality_inc_3rss008z_identify", "sensor.third_reality_inc_3rss008z_battery", "switch.third_reality_inc_3rss008z_switch", "sensor.third_reality_inc_3rss008z_rssi", @@ -5213,7 +5213,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5253,7 +5253,7 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.visonic_mct_340_e_identifybutton", + "button.visonic_mct_340_e_identify", "sensor.visonic_mct_340_e_battery", "sensor.visonic_mct_340_e_temperature", "binary_sensor.visonic_mct_340_e_iaszone", @@ -5269,7 +5269,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5309,9 +5309,9 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.zen_within_zen_01_identifybutton", + "button.zen_within_zen_01_identify", "sensor.zen_within_zen_01_battery", - "sensor.zen_within_zen_01_thermostathvacaction", + "sensor.zen_within_zen_01_hvac_action", "climate.zen_within_zen_01_zenwithinthermostat", "sensor.zen_within_zen_01_rssi", "sensor.zen_within_zen_01_lqi", @@ -5320,7 +5320,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat", "fan"], @@ -5345,7 +5345,7 @@ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_thermostathvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", }, }, }, @@ -5442,7 +5442,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.netvox_z308e3ed_identifybutton", + "button.netvox_z308e3ed_identify", "sensor.netvox_z308e3ed_battery", "binary_sensor.netvox_z308e3ed_iaszone", "sensor.netvox_z308e3ed_rssi", @@ -5457,7 +5457,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5492,10 +5492,10 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_e11_g13_identifybutton", + "button.sengled_e11_g13_identify", "light.sengled_e11_g13_mintransitionlight", - "sensor.sengled_e11_g13_smartenergymetering", - "sensor.sengled_e11_g13_smartenergysummation", + "sensor.sengled_e11_g13_instantaneous_demand", + "sensor.sengled_e11_g13_summation_delivered", "sensor.sengled_e11_g13_rssi", "sensor.sengled_e11_g13_lqi", ], @@ -5508,17 +5508,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5548,10 +5548,10 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_e12_n14_identifybutton", + "button.sengled_e12_n14_identify", "light.sengled_e12_n14_mintransitionlight", - "sensor.sengled_e12_n14_smartenergymetering", - "sensor.sengled_e12_n14_smartenergysummation", + "sensor.sengled_e12_n14_instantaneous_demand", + "sensor.sengled_e12_n14_summation_delivered", "sensor.sengled_e12_n14_rssi", "sensor.sengled_e12_n14_lqi", ], @@ -5564,17 +5564,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5604,10 +5604,10 @@ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_z01_a19nae26_identifybutton", + "button.sengled_z01_a19nae26_identify", "light.sengled_z01_a19nae26_mintransitionlight", - "sensor.sengled_z01_a19nae26_smartenergymetering", - "sensor.sengled_z01_a19nae26_smartenergysummation", + "sensor.sengled_z01_a19nae26_instantaneous_demand", + "sensor.sengled_z01_a19nae26_summation_delivered", "sensor.sengled_z01_a19nae26_rssi", "sensor.sengled_z01_a19nae26_lqi", ], @@ -5620,17 +5620,17 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5660,7 +5660,7 @@ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.unk_manufacturer_unk_model_identifybutton", + "button.unk_manufacturer_unk_model_identify", "cover.unk_manufacturer_unk_model_shade", "sensor.unk_manufacturer_unk_model_rssi", "sensor.unk_manufacturer_unk_model_lqi", @@ -5669,7 +5669,7 @@ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off", "shade"], @@ -5962,7 +5962,7 @@ DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ "sensor.efektalab_ru_efekta_pws_battery", - "sensor.efektalab_ru_efekta_pws_soilmoisture", + "sensor.efektalab_ru_efekta_pws_soil_moisture", "sensor.efektalab_ru_efekta_pws_temperature", "sensor.efektalab_ru_efekta_pws_rssi", "sensor.efektalab_ru_efekta_pws_lqi", @@ -5976,7 +5976,7 @@ ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { DEV_SIG_CHANNELS: ["soil_moisture"], DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", - DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soilmoisture", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soil_moisture", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index c2079564dcf5cc..49fbe96f1623ae 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,4 +1,16 @@ """Provide common test tools for Z-Wave JS.""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from zwave_js_server.model.node.data_model import NodeDataType + +from homeassistant.components.zwave_js.helpers import ( + ZwaveValueMatcher, + value_matches_matcher, +) + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" TAMPER_SENSOR = "binary_sensor.multisensor_6_tampering_product_cover_removed" @@ -37,3 +49,16 @@ DEHUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_dehumidifier" PROPERTY_ULTRAVIOLET = "Ultraviolet" + + +def replace_value_of_zwave_value( + node_data: NodeDataType, matchers: list[ZwaveValueMatcher], new_value: Any +) -> NodeDataType: + """Replace the value of a zwave value that matches the input matchers.""" + new_node_data = deepcopy(node_data) + for value_data in new_node_data["values"]: + for matcher in matchers: + if value_matches_matcher(matcher, value_data): + value_data["value"] = new_value + + return new_node_data diff --git a/tests/components/zwave_js/test_addon.py b/tests/components/zwave_js/test_addon.py new file mode 100644 index 00000000000000..45f732c1aa260a --- /dev/null +++ b/tests/components/zwave_js/test_addon.py @@ -0,0 +1,30 @@ +"""Tests for Z-Wave JS addon module.""" +import pytest + +from homeassistant.components.zwave_js.addon import AddonError, get_addon_manager +from homeassistant.components.zwave_js.const import ( + CONF_ADDON_DEVICE, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, +) + + +async def test_not_installed_raises_exception(hass, addon_not_installed): + """Test addon not installed raises exception.""" + addon_manager = get_addon_manager(hass) + + addon_config = { + CONF_ADDON_DEVICE: "/test", + CONF_ADDON_S0_LEGACY_KEY: "123", + CONF_ADDON_S2_ACCESS_CONTROL_KEY: "456", + CONF_ADDON_S2_AUTHENTICATED_KEY: "789", + CONF_ADDON_S2_UNAUTHENTICATED_KEY: "012", + } + + with pytest.raises(AddonError): + await addon_manager.async_configure_addon(addon_config) + + with pytest.raises(AddonError): + await addon_manager.async_update_addon() diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 2a1c13b0db2769..3d4971e1ce45fb 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -3,7 +3,7 @@ from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -69,6 +69,29 @@ async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) assert state.state == STATE_ON + # Test state updates from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 53, + "args": { + "commandClassName": "Binary Sensor", + "commandClass": 48, + "endpoint": 0, + "property": "Any", + "newValue": None, + "prevValue": True, + "propertyName": "Any", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) + assert state.state == STATE_UNKNOWN + async def test_disabled_legacy_sensor(hass, multisensor_6, integration): """Test disabled legacy boolean binary sensor.""" @@ -198,3 +221,26 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_OFF + + # door state unknown + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "doorStatus", + "newValue": None, + "prevValue": "open", + "propertyName": "doorStatus", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 93d1849f4513d3..62dfacc7549b3a 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -1,6 +1,11 @@ """Test the Z-Wave JS climate platform.""" import pytest +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_OPERATING_STATE_PROPERTY, +) from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -25,6 +30,7 @@ HVACMode, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -37,6 +43,7 @@ CLIMATE_FLOOR_THERMOSTAT_ENTITY, CLIMATE_MAIN_HEAT_ACTIONNER, CLIMATE_RADIO_THERMOSTAT_ENTITY, + replace_value_of_zwave_value, ) @@ -637,3 +644,25 @@ async def test_temp_unit_fix( state = hass.states.get("climate.z_wave_thermostat") assert state assert state.attributes["current_temperature"] == 21.1 + + +async def test_thermostat_unknown_values( + hass, client, climate_radio_thermostat_ct100_plus_state, integration +): + """Test a thermostat v2 with unknown values.""" + node_state = replace_value_of_zwave_value( + climate_radio_thermostat_ct100_plus_state, + [ + ZwaveValueMatcher( + THERMOSTAT_OPERATING_STATE_PROPERTY, + command_class=CommandClass.THERMOSTAT_OPERATING_STATE, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + + assert ATTR_HVAC_ACTION not in state.attributes diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index d4f159f2510e6e..f58b4187469108 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE -from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from tests.common import MockConfigEntry @@ -162,7 +162,12 @@ def mock_list_ports_fixture(serial_port) -> Generator[MagicMock, None, None]: another_port.description = "New serial port" another_port.serial_number = "5678" another_port.pid = 8765 - mock_list_ports.return_value = [serial_port, another_port] + no_vid_port = copy(serial_port) + no_vid_port.device = "/no_vid" + no_vid_port.description = "Port without vid" + no_vid_port.serial_number = "9123" + no_vid_port.vid = None + mock_list_ports.return_value = [serial_port, another_port, no_vid_port] yield mock_list_ports @@ -326,7 +331,11 @@ async def test_supervisor_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) with patch( @@ -366,7 +375,11 @@ async def test_supervisor_discovery_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "abort" @@ -388,7 +401,11 @@ async def test_clean_discovery_on_user_create( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "form" @@ -454,7 +471,11 @@ async def test_abort_discovery_with_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "abort" @@ -478,13 +499,39 @@ async def test_abort_hassio_discovery_with_existing_flow( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result2["type"] == "abort" assert result2["reason"] == "already_in_progress" +async def test_abort_hassio_discovery_for_other_addon( + hass, supervisor, addon_installed, addon_options +): + """Test hassio discovery flow is aborted for a non official add-on discovery.""" + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config={ + "addon": "Other Z-Wave JS", + "host": "host1", + "port": 3001, + }, + name="Other Z-Wave JS", + slug="other_addon", + ), + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "not_zwave_js_addon" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery( hass, @@ -673,7 +720,11 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["step_id"] == "hassio_confirm" @@ -753,7 +804,11 @@ async def test_discovery_addon_not_installed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["step_id"] == "hassio_confirm" @@ -834,7 +889,11 @@ async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_op result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "form" diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 54f71fa00d371c..f26b0d29069130 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,5 +1,11 @@ """Test the Z-Wave JS cover platform.""" +from zwave_js_server.const import ( + CURRENT_STATE_PROPERTY, + CURRENT_VALUE_PROPERTY, + CommandClass, +) from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -9,6 +15,7 @@ SERVICE_OPEN_COVER, CoverDeviceClass, ) +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_CLOSED, @@ -18,6 +25,8 @@ STATE_UNKNOWN, ) +from .common import replace_value_of_zwave_value + WINDOW_COVER_ENTITY = "cover.zws_12" GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" @@ -600,3 +609,59 @@ async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): state = hass.states.get(GDC_COVER_ENTITY) assert state.state == STATE_UNKNOWN + + +async def test_motor_barrier_cover_no_primary_value( + hass, client, gdc_zw062_state, integration +): + """Test the cover entity where primary value value is None.""" + node_state = replace_value_of_zwave_value( + gdc_zw062_state, + [ + ZwaveValueMatcher( + property_=CURRENT_STATE_PROPERTY, + command_class=CommandClass.BARRIER_OPERATOR, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(GDC_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GARAGE + + assert state.state == STATE_UNKNOWN + assert ATTR_CURRENT_POSITION not in state.attributes + + +async def test_fibaro_FGR222_shutter_cover_no_tilt( + hass, client, fibaro_fgr222_shutter_state, integration +): + """Test tilt function of the Fibaro Shutter devices with tilt value is None.""" + node_state = replace_value_of_zwave_value( + fibaro_fgr222_shutter_state, + [ + ZwaveValueMatcher( + property_="fibaro", + command_class=CommandClass.MANUFACTURER_PROPRIETARY, + property_key="venetianBlindsTilt", + ), + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + ), + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5f35a568f37199..03ebd3b6453e80 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,7 +1,12 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( + ATTR_CODE_SLOT, + ATTR_USERCODE, + CURRENT_MODE_PROPERTY, +) from zwave_js_server.event import Event -from zwave_js_server.model.node import NodeStatus +from zwave_js_server.model.node import Node, NodeStatus from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -9,6 +14,7 @@ SERVICE_UNLOCK, ) from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, SERVICE_SET_LOCK_USERCODE, @@ -17,10 +23,11 @@ ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, ) -from .common import SCHLAGE_BE469_LOCK_ENTITY +from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value async def test_door_lock(hass, client, lock_schlage_be469, integration): @@ -160,3 +167,23 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): async def test_only_one_lock(hass, client, lock_home_connect_620, integration): """Test node with both Door Lock and Lock CC values only gets one lock entity.""" assert len(hass.states.async_entity_ids("lock")) == 1 + + +async def test_door_lock_no_value(hass, client, lock_schlage_be469_state, integration): + """Test a lock entity with door lock command class that has no value for mode.""" + node_state = replace_value_of_zwave_value( + lock_schlage_be469_state, + [ + ZwaveValueMatcher( + property_=CURRENT_MODE_PROPERTY, + command_class=CommandClass.DOOR_LOCK, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 1cf5fb54304dff..e278c11ca7252d 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -1,15 +1,19 @@ """Test the Z-Wave JS number platform.""" from unittest.mock import MagicMock +from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event from zwave_js_server.model.node import Node +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory import homeassistant.helpers.entity_registry as er +from .common import replace_value_of_zwave_value + DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" @@ -265,3 +269,27 @@ async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integ state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) assert state.state == "Strobe ONLY" + + +async def test_multilevel_switch_select_no_value( + hass, client, fortrezz_ssa1_siren_state, integration +): + """Test Multilevel Switch CC based select entity with primary value is None.""" + node_state = replace_value_of_zwave_value( + fortrezz_ssa1_siren_state, + [ + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index b84ab32f618b4b..d485c877cc42f0 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -1,11 +1,14 @@ """Test the Z-Wave JS switch platform.""" +from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN -from .common import SWITCH_ENTITY +from .common import SWITCH_ENTITY, replace_value_of_zwave_value async def test_switch(hass, hank_binary_switch, integration, client): @@ -14,7 +17,7 @@ async def test_switch(hass, hank_binary_switch, integration, client): node = hank_binary_switch assert state - assert state.state == "off" + assert state.state == STATE_OFF # Test turning on await hass.services.async_call( @@ -178,3 +181,25 @@ async def test_barrier_signaling_switch(hass, gdc_zw062, integration, client): state = hass.states.get(entity) assert state.state == STATE_ON + + +async def test_switch_no_value(hass, hank_binary_switch_state, integration, client): + """Test the switch where primary value value is None.""" + node_state = replace_value_of_zwave_value( + hank_binary_switch_state, + [ + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_BINARY, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/conftest.py b/tests/conftest.py index dacd9d09c9188e..1047293ee1618f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,6 +79,11 @@ asyncio.set_event_loop_policy = lambda policy: None +def pytest_addoption(parser): + """Register custom pytest options.""" + parser.addoption("--dburl", action="store", default="sqlite://") + + def pytest_configure(config): """Register marker for tests that log exceptions.""" config.addinivalue_line( @@ -108,8 +113,19 @@ def pytest_runtest_setup(): def adapt_datetime(val): return val.isoformat(" ") + # Setup HAFakeDatetime converter for sqlite3 sqlite3.register_adapter(HAFakeDatetime, adapt_datetime) + # Setup HAFakeDatetime converter for pymysql + try: + import MySQLdb.converters as MySQLdb_converters + except ImportError: + pass + else: + MySQLdb_converters.conversions[ + HAFakeDatetime + ] = MySQLdb_converters.DateTime2literal + def ha_datetime_to_fakedatetime(datetime): """Convert datetime to FakeDatetime. @@ -312,9 +328,17 @@ async def finalize() -> None: @pytest.fixture -def hass(loop, load_registries, hass_storage, request): +def hass_fixture_setup(): + """Fixture whichis truthy if the hass fixture has been setup.""" + return [] + + +@pytest.fixture +def hass(hass_fixture_setup, loop, load_registries, hass_storage, request): """Fixture to provide a test instance of Home Assistant.""" + hass_fixture_setup.append(True) + orig_tz = dt_util.DEFAULT_TIME_ZONE def exc_handle(loop, context): @@ -857,7 +881,29 @@ def recorder_config(): @pytest.fixture -def hass_recorder(enable_nightly_purge, enable_statistics, hass_storage): +def recorder_db_url(pytestconfig): + """Prepare a default database for tests and return a connection URL.""" + db_url: str = pytestconfig.getoption("dburl") + if db_url.startswith("mysql://"): + import sqlalchemy_utils + + charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci" + assert not sqlalchemy_utils.database_exists(db_url) + sqlalchemy_utils.create_database(db_url, encoding=charset) + elif db_url.startswith("postgresql://"): + pass + yield db_url + if db_url.startswith("mysql://"): + sqlalchemy_utils.drop_database(db_url) + + +@pytest.fixture +def hass_recorder( + recorder_db_url, + enable_nightly_purge, + enable_statistics, + hass_storage, +): """Home Assistant fixture with in-memory recorder.""" original_tz = dt_util.DEFAULT_TIME_ZONE @@ -876,7 +922,7 @@ def hass_recorder(enable_nightly_purge, enable_statistics, hass_storage): def setup_recorder(config=None): """Set up with params.""" - init_recorder_component(hass, config) + init_recorder_component(hass, config, recorder_db_url) hass.start() hass.block_till_done() hass.data[recorder.DATA_INSTANCE].block_till_done() @@ -889,17 +935,15 @@ def setup_recorder(config=None): dt_util.DEFAULT_TIME_ZONE = original_tz -async def _async_init_recorder_component(hass, add_config=None): +async def _async_init_recorder_component(hass, add_config=None, db_url=None): """Initialize the recorder asynchronously.""" config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: - config[recorder.CONF_DB_URL] = "sqlite://" # In memory DB + config[recorder.CONF_DB_URL] = db_url if recorder.CONF_COMMIT_INTERVAL not in config: config[recorder.CONF_COMMIT_INTERVAL] = 0 - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.migrate_schema" - ): + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): if recorder.DOMAIN not in hass.data: recorder_helper.async_initialize_recorder(hass) assert await async_setup_component( @@ -914,9 +958,13 @@ async def _async_init_recorder_component(hass, add_config=None): @pytest.fixture async def async_setup_recorder_instance( - enable_nightly_purge, enable_statistics + recorder_db_url, + hass_fixture_setup, + enable_nightly_purge, + enable_statistics, ) -> AsyncGenerator[SetupRecorderInstanceT, None]: """Yield callable to setup recorder instance.""" + assert not hass_fixture_setup nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None @@ -934,7 +982,7 @@ async def async_setup_recorder( hass: HomeAssistant, config: ConfigType | None = None ) -> recorder.Recorder: """Setup and return recorder instance.""" # noqa: D401 - await _async_init_recorder_component(hass, config) + await _async_init_recorder_component(hass, config, recorder_db_url) await hass.async_block_till_done() instance = hass.data[recorder.DATA_INSTANCE] # The recorder's worker is not started until Home Assistant is running diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b7e4caf68c766f..65db1291f9db5e 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -42,7 +42,7 @@ def setup_comp(hass): """Initialize components.""" hass.config.set_time_zone(hass.config.time_zone) hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) ) @@ -2735,9 +2735,9 @@ async def test_if_action_after_sunset_with_offset(hass, hass_ws_client, calls): ) -async def test_if_action_before_and_after_during(hass, hass_ws_client, calls): +async def test_if_action_after_and_before_during(hass, hass_ws_client, calls): """ - Test if action was after sunset and before sunrise. + Test if action was after sunrise and before sunset. This is true from sunrise until sunset. """ @@ -2837,6 +2837,128 @@ async def test_if_action_before_and_after_during(hass, hass_ws_client, calls): ) +async def test_if_action_before_or_after_during(hass, hass_ws_client, calls): + """ + Test if action was before sunrise or after sunset. + + This is true from midnight until sunrise and from sunset until midnight + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "after": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # 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): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + async def test_if_action_before_sunrise_no_offset_kotzebue(hass, hass_ws_client, calls): """ Test if action was before sunrise. diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 57b89e64cce40f..536ccaaac68085 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3333,9 +3333,7 @@ async def test_track_sunrise(hass): # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3400,9 +3398,7 @@ async def test_track_sunrise_update_location(hass): # Setup sun component hass.config.latitude = 32.87336 hass.config.longitude = 117.22743 - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3476,9 +3472,7 @@ async def test_track_sunset(hass): # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) # Get next sunrise/sunset utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_recorder.py b/tests/helpers/test_recorder.py index 9410117acb490f..9c11a372cbeb97 100644 --- a/tests/helpers/test_recorder.py +++ b/tests/helpers/test_recorder.py @@ -9,7 +9,7 @@ async def test_async_migration_in_progress( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test async_migration_in_progress wraps the recorder.""" with patch( diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index bc32ffa35fdeb9..bccf99a4274d7d 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -1,6 +1,6 @@ """Test starting HA helpers.""" from homeassistant import core -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.helpers import start @@ -100,7 +100,7 @@ def cb_at_start(hass): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_when_running(hass, caplog): +async def test_cancelling_at_start_when_running(hass, caplog): """Test cancelling at start when already running.""" assert hass.state == core.CoreState.running assert hass.is_running @@ -120,7 +120,7 @@ async def cb_at_start(hass): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_when_starting(hass): +async def test_cancelling_at_start_when_starting(hass): """Test cancelling at start when yet to start.""" hass.state = core.CoreState.not_running assert not hass.is_running @@ -139,3 +139,148 @@ def cb_at_start(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_at_started_when_running_awaitable(hass): + """Test at started when already started.""" + assert hass.state == core.CoreState.running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Test the job is not run if state is CoreState.starting + hass.state = core.CoreState.starting + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_at_started_when_running_callback(hass, caplog): + """Test at started when already running.""" + assert hass.state == core.CoreState.running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + assert len(calls) == 1 + + # Test the job is not run if state is CoreState.starting + hass.state = core.CoreState.starting + + start.async_at_started(hass, cb_at_start)() + assert len(calls) == 1 + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_at_started_when_starting_awaitable(hass): + """Test at started when yet to start.""" + hass.state = core.CoreState.not_running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_at_started_when_starting_callback(hass, caplog): + """Test at started when yet to start.""" + hass.state = core.CoreState.not_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + cancel = start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 1 + + cancel() + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_cancelling_at_started_when_running(hass, caplog): + """Test cancelling at start when already running.""" + assert hass.state == core.CoreState.running + assert hass.is_running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + await hass.async_block_till_done() + assert len(calls) == 1 + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_cancelling_at_started_when_starting(hass): + """Test cancelling at start when yet to start.""" + hass.state = core.CoreState.not_running + assert not hass.is_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 0 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index fa9ec4e76d67eb..63a6154a190333 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -43,13 +43,14 @@ def _set_up_units(hass): """Set up the tests.""" hass.config.units = UnitSystem( "custom", - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_KILOMETERS_PER_HOUR, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=LENGTH_MILLIMETERS, + conversions={}, + length=LENGTH_METERS, + mass=MASS_GRAMS, + pressure=PRESSURE_PA, + temperature=TEMP_CELSIUS, + volume=VOLUME_LITERS, + wind_speed=SPEED_KILOMETERS_PER_HOUR, ) @@ -973,12 +974,27 @@ def test_average(hass): assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 + # Testing of default values + assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + with pytest.raises(TemplateError): template.Template("{{ 1 | average }}", hass).async_render() with pytest.raises(TemplateError): template.Template("{{ average() }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ average([]) }}", hass).async_render() + def test_min(hass): """Test the min filter.""" @@ -1332,6 +1348,22 @@ def test_is_state(hass): ) assert tpl.async_render() is False + tpl = template.Template( + """ +{% if "test.object" is is_state("available") %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | select("is_state", "available") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "test.object" + def test_is_state_attr(hass): """Test is_state_attr method.""" @@ -1352,10 +1384,28 @@ def test_is_state_attr(hass): ) assert tpl.async_render() is False + tpl = template.Template( + """ +{% if "test.object" is is_state_attr("mode", "on") %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | select("is_state_attr", "mode", "on") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "test.object" + def test_state_attr(hass): """Test state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set( + "test.object", "available", {"effect": "action", "mode": "on"} + ) tpl = template.Template( """ {% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %} @@ -1372,6 +1422,22 @@ def test_state_attr(hass): ) assert tpl.async_render() is True + tpl = template.Template( + """ +{% if "test.object" | state_attr("mode") == "on" %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | map("state_attr", "effect") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "action" + def test_states_function(hass): """Test using states as a function.""" @@ -1382,6 +1448,22 @@ def test_states_function(hass): tpl2 = template.Template('{{ states("test.object2") }}', hass) assert tpl2.async_render() == "unknown" + tpl = template.Template( + """ +{% if "test.object" | states == "available" %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | map("states") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "available" + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", @@ -2458,6 +2540,32 @@ async def test_integration_entities(hass): assert info.rate_limit is None +async def test_config_entry_id(hass): + """Test config_entry_id function.""" + config_entry = MockConfigEntry(domain="light", title="Some integration") + config_entry.add_to_hass(hass) + entity_registry = mock_registry(hass) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", config_entry=config_entry + ) + + info = render_to_info(hass, "{{ 'sensor.fail' | config_entry_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 56 | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info( + hass, f"{{{{ config_entry_id('{entity_entry.entity_id}') }}}}" + ) + assert_result_info(info, config_entry.entry_id) + assert info.rate_limit is None + + async def test_device_id(hass): """Test device_id function.""" config_entry = MockConfigEntry(domain="light") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 56c15f493376b6..e51f4d315eefcf 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -9,7 +9,7 @@ from homeassistant import bootstrap, core, runner import homeassistant.config as config_util -from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -748,7 +748,7 @@ def _bootstrap_integrations(data): integrations.append(data) async_dispatcher_connect( - hass, SIGNAL_BOOTSTRAP_INTEGRATONS, _bootstrap_integrations + hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, _bootstrap_integrations ) with patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05): await bootstrap._async_set_up_integrations( diff --git a/tests/test_config.py b/tests/test_config.py index 9b3f9d8755fa15..0a125d8f121a35 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,17 +22,22 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - CONF_TEMPERATURE_UNIT, CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, __version__, ) -from homeassistant.core import ConfigSource, HomeAssistantError +from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError from homeassistant.helpers import config_validation as cv import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity from homeassistant.loader import async_get_integration +from homeassistant.util.unit_system import ( + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from homeassistant.util.yaml import SECRET_YAML from tests.common import get_test_config_dir, patch_yaml_files @@ -440,7 +445,7 @@ async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_stor assert hass.config.config_source is ConfigSource.STORAGE -async def test_updating_configuration(hass, hass_storage): +async def test_igration_and_updating_configuration(hass, hass_storage): """Test updating configuration stores the new configuration.""" core_data = { "data": { @@ -449,7 +454,7 @@ async def test_updating_configuration(hass, hass_storage): "location_name": "Home", "longitude": 13, "time_zone": "Europe/Copenhagen", - "unit_system": "metric", + "unit_system": "imperial", "external_url": "https://www.example.com", "internal_url": "http://example.local", "currency": "BTC", @@ -464,10 +469,14 @@ async def test_updating_configuration(hass, hass_storage): ) await hass.config.async_update(latitude=50, currency="USD") - new_core_data = copy.deepcopy(core_data) - new_core_data["data"]["latitude"] = 50 - new_core_data["data"]["currency"] = "USD" - assert hass_storage["core.config"] == new_core_data + expected_new_core_data = copy.deepcopy(core_data) + # From async_update above + expected_new_core_data["data"]["latitude"] = 50 + expected_new_core_data["data"]["currency"] = "USD" + # 1.1 -> 1.2 store migration with migrated unit system + expected_new_core_data["data"]["unit_system_v2"] = "us_customary" + expected_new_core_data["minor_version"] = 2 + assert hass_storage["core.config"] == expected_new_core_data assert hass.config.latitude == 50 assert hass.config.currency == "USD" @@ -538,34 +547,6 @@ async def test_loading_configuration(hass): assert hass.config.currency == "EUR" -async def test_loading_configuration_temperature_unit(hass): - """Test backward compatibility when loading core config.""" - await config_util.async_process_ha_core_config( - hass, - { - "latitude": 60, - "longitude": 50, - "elevation": 25, - "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", - "time_zone": "America/New_York", - "external_url": "https://www.example.com", - "internal_url": "http://example.local", - }, - ) - - assert hass.config.latitude == 60 - assert hass.config.longitude == 50 - assert hass.config.elevation == 25 - assert hass.config.location_name == "Huis" - assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC - assert hass.config.time_zone == "America/New_York" - assert hass.config.external_url == "https://www.example.com" - assert hass.config.internal_url == "http://example.local" - assert hass.config.config_source is ConfigSource.YAML - assert hass.config.currency == "EUR" - - async def test_loading_configuration_default_media_dirs_docker(hass): """Test loading core config onto hass object.""" with patch("homeassistant.config.is_docker_env", return_value=True): @@ -591,7 +572,7 @@ async def test_loading_configuration_from_packages(hass): "longitude": -1, "elevation": 500, "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "time_zone": "Europe/Madrid", "external_url": "https://www.example.com", "internal_url": "http://example.local", @@ -615,13 +596,42 @@ async def test_loading_configuration_from_packages(hass): "longitude": -1, "elevation": 500, "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "time_zone": "Europe/Madrid", "packages": {"empty_package": None}, }, ) +@pytest.mark.parametrize( + "unit_system_name, expected_unit_system", + [ + (CONF_UNIT_SYSTEM_METRIC, METRIC_SYSTEM), + (CONF_UNIT_SYSTEM_IMPERIAL, US_CUSTOMARY_SYSTEM), + (_CONF_UNIT_SYSTEM_US_CUSTOMARY, US_CUSTOMARY_SYSTEM), + ], +) +async def test_loading_configuration_unit_system( + hass: HomeAssistant, unit_system_name: str, expected_unit_system: UnitSystem +) -> None: + """Test backward compatibility when loading core config.""" + await config_util.async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Huis", + "unit_system": unit_system_name, + "time_zone": "America/New_York", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", + }, + ) + + assert hass.config.units is expected_unit_system + + @patch("homeassistant.helpers.check_config.async_check_ha_config_file") async def test_check_ha_config_file_correct(mock_check, hass): """Check that restart propagates to stop.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d2d4ffe1134f6c..83343146d47030 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2507,7 +2507,10 @@ async def async_step_import(self, user_input): (config_entries.SOURCE_HOMEKIT, BaseServiceInfo()), (config_entries.SOURCE_DHCP, BaseServiceInfo()), (config_entries.SOURCE_ZEROCONF, BaseServiceInfo()), - (config_entries.SOURCE_HASSIO, HassioServiceInfo(config={})), + ( + config_entries.SOURCE_HASSIO, + HassioServiceInfo(config={}, name="Test", slug="test"), + ), ), ) async def test_flow_with_default_discovery(hass, manager, discovery_source): diff --git a/tests/test_core.py b/tests/test_core.py index 67513ea8b17b81..017c8b3b607dee 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -925,7 +925,7 @@ def service_handler(_): await hass.async_block_till_done() -def test_config_defaults(): +async def test_config_defaults(): """Test config defaults.""" hass = Mock() config = ha.Config(hass) @@ -950,21 +950,21 @@ def test_config_defaults(): assert config.currency == "EUR" -def test_config_path_with_file(): +async def test_config_path_with_file(): """Test get_config_path method.""" config = ha.Config(None) config.config_dir = "/test/ha-config" assert config.path("test.conf") == "/test/ha-config/test.conf" -def test_config_path_with_dir_and_file(): +async def test_config_path_with_dir_and_file(): """Test get_config_path method.""" config = ha.Config(None) config.config_dir = "/test/ha-config" assert config.path("dir", "test.conf") == "/test/ha-config/dir/test.conf" -def test_config_as_dict(): +async def test_config_as_dict(): """Test as dict.""" config = ha.Config(None) config.config_dir = "/test/ha-config" @@ -994,7 +994,7 @@ def test_config_as_dict(): assert expected == config.as_dict() -def test_config_is_allowed_path(): +async def test_config_is_allowed_path(): """Test is_allowed_path method.""" config = ha.Config(None) with TemporaryDirectory() as tmp_dir: @@ -1026,7 +1026,7 @@ def test_config_is_allowed_path(): config.is_allowed_path(None) -def test_config_is_allowed_external_url(): +async def test_config_is_allowed_external_url(): """Test is_allowed_external_url method.""" config = ha.Config(None) config.allowlist_external_urls = [ @@ -1273,7 +1273,7 @@ async def test_additional_data_in_core_config(hass, hass_storage): async def test_incorrect_internal_external_url(hass, hass_storage, caplog): - """Test that we warn when detecting invalid internal/extenral url.""" + """Test that we warn when detecting invalid internal/external url.""" config = ha.Config(hass) hass_storage[ha.CORE_STORAGE_KEY] = { @@ -1287,6 +1287,8 @@ async def test_incorrect_internal_external_url(hass, hass_storage, caplog): assert "Invalid external_url set" not in caplog.text assert "Invalid internal_url set" not in caplog.text + config = ha.Config(hass) + hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": { diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index a4b5a182edc73d..3c78e7acce366c 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -37,13 +37,13 @@ class MockLight(MockToggleEntity, LightEntity): """Mock light class.""" color_mode = None - max_mireds = 500 - min_mireds = 153 + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2000 supported_color_modes = None supported_features = 0 brightness = None - color_temp = None + color_temp_kelvin = None hs_color = None rgb_color = None rgbw_color = None @@ -61,7 +61,7 @@ def turn_on(self, **kwargs): "rgb_color", "rgbw_color", "rgbww_color", - "color_temp", + "color_temp_kelvin", ]: setattr(self, key, value) if key == "white": diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 6404a126807d35..9584e47ba0b0b8 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -112,6 +112,11 @@ def state_class(self): """Return the state class of this sensor.""" return self._handle("state_class") + @property + def suggested_unit_of_measurement(self): + """Return the state class of this sensor.""" + return self._handle("suggested_unit_of_measurement") + class MockRestoreSensor(MockSensor, RestoreSensor): """Mock RestoreSensor class.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index b77540acc2be63..95d2ffc0fd70f5 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -370,82 +370,84 @@ def test_get_color_in_voluptuous(): def test_color_rgb_to_rgbww(): """Test color_rgb_to_rgbww conversions.""" - assert color_util.color_rgb_to_rgbww(255, 255, 255, 154, 370) == ( + # Light with mid point at ~4600K (warm white) -> output compensated by adding blue + assert color_util.color_rgb_to_rgbww(255, 255, 255, 2702, 6493) == ( 0, 54, 98, 255, 255, ) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 100, 1000) == ( + # Light with mid point at ~5500K (less warm white) -> output compensated by adding less blue + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1000, 10000) == ( 255, 255, 255, 0, 0, ) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 1000) == ( + # Light with mid point at ~1MK (unrealistically cold white) -> output compensated by adding red + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1000, 1000000) == ( 0, 118, 241, 255, 255, ) - assert color_util.color_rgb_to_rgbww(128, 128, 128, 154, 370) == ( + assert color_util.color_rgb_to_rgbww(128, 128, 128, 2702, 6493) == ( 0, 27, 49, 128, 128, ) - assert color_util.color_rgb_to_rgbww(64, 64, 64, 154, 370) == (0, 14, 25, 64, 64) - assert color_util.color_rgb_to_rgbww(32, 64, 16, 154, 370) == (9, 64, 0, 38, 38) - assert color_util.color_rgb_to_rgbww(0, 0, 0, 154, 370) == (0, 0, 0, 0, 0) - assert color_util.color_rgb_to_rgbww(0, 0, 0, 0, 100) == (0, 0, 0, 0, 0) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 5) == (103, 69, 0, 255, 255) + assert color_util.color_rgb_to_rgbww(64, 64, 64, 2702, 6493) == (0, 14, 25, 64, 64) + assert color_util.color_rgb_to_rgbww(32, 64, 16, 2702, 6493) == (9, 64, 0, 38, 38) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 2702, 6493) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 10000, 1000000) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 200000, 1000000) == ( + 103, + 69, + 0, + 255, + 255, + ) def test_color_rgbww_to_rgb(): """Test color_rgbww_to_rgb conversions.""" - assert color_util.color_rgbww_to_rgb(0, 54, 98, 255, 255, 154, 370) == ( + assert color_util.color_rgbww_to_rgb(0, 54, 98, 255, 255, 2702, 6493) == ( 255, 255, 255, ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 154, 370) == ( + # rgb fully on, + both white channels turned off -> rgb fully on + assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 2702, 6493) == ( 255, 255, 255, ) - assert color_util.color_rgbww_to_rgb(0, 118, 241, 255, 255, 154, 370) == ( + # r < g < b + both white channels fully enabled -> r < g < b capped at 255 + assert color_util.color_rgbww_to_rgb(0, 118, 241, 255, 255, 2702, 6493) == ( 163, 204, 255, ) - assert color_util.color_rgbww_to_rgb(0, 27, 49, 128, 128, 154, 370) == ( + # r < g < b + both white channels 50% enabled -> r < g < b capped at 128 + assert color_util.color_rgbww_to_rgb(0, 27, 49, 128, 128, 2702, 6493) == ( 128, 128, 128, ) - assert color_util.color_rgbww_to_rgb(0, 14, 25, 64, 64, 154, 370) == (64, 64, 64) - assert color_util.color_rgbww_to_rgb(9, 64, 0, 38, 38, 154, 370) == (32, 64, 16) - assert color_util.color_rgbww_to_rgb(0, 0, 0, 0, 0, 154, 370) == (0, 0, 0) - assert color_util.color_rgbww_to_rgb(103, 69, 0, 255, 255, 153, 370) == ( + # r < g < b + both white channels 25% enabled -> r < g < b capped at 64 + assert color_util.color_rgbww_to_rgb(0, 14, 25, 64, 64, 2702, 6493) == (64, 64, 64) + assert color_util.color_rgbww_to_rgb(9, 64, 0, 38, 38, 2702, 6493) == (32, 64, 16) + assert color_util.color_rgbww_to_rgb(0, 0, 0, 0, 0, 2702, 6493) == (0, 0, 0) + assert color_util.color_rgbww_to_rgb(103, 69, 0, 255, 255, 2702, 6535) == ( 255, 193, 112, ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 0, 0) == (255, 255, 255) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 0) == ( - 255, - 161, - 128, - ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 370) == ( - 255, - 245, - 237, - ) def test_color_temperature_to_rgbww(): @@ -454,42 +456,45 @@ def test_color_temperature_to_rgbww(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.color_temperature_to_rgbww(153, 255, 153, 500) == ( + # Coldest color temperature -> only cold channel enabled + assert color_util.color_temperature_to_rgbww(6535, 255, 2000, 6535) == ( 0, 0, 0, 255, 0, ) - assert color_util.color_temperature_to_rgbww(153, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(6535, 128, 2000, 6535) == ( 0, 0, 0, 128, 0, ) - assert color_util.color_temperature_to_rgbww(500, 255, 153, 500) == ( + # Warmest color temperature -> only cold channel enabled + assert color_util.color_temperature_to_rgbww(2000, 255, 2000, 6535) == ( 0, 0, 0, 0, 255, ) - assert color_util.color_temperature_to_rgbww(500, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(2000, 128, 2000, 6535) == ( 0, 0, 0, 0, 128, ) - assert color_util.color_temperature_to_rgbww(347, 255, 153, 500) == ( + # Warmer than mid point color temperature -> More warm than cold channel enabled + assert color_util.color_temperature_to_rgbww(2881, 255, 2000, 6535) == ( 0, 0, 0, 112, 143, ) - assert color_util.color_temperature_to_rgbww(347, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(2881, 128, 2000, 6535) == ( 0, 0, 0, @@ -504,39 +509,36 @@ def test_rgbww_to_color_temperature(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.rgbww_to_color_temperature( - ( - 0, - 0, - 0, - 255, - 0, - ), - 153, - 500, - ) == (153, 255) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 128, 0), 153, 500) == ( - 153, + # Only cold channel enabled -> coldest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 255, 0), 2000, 6535) == ( + 6535, + 255, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 128, 0), 2000, 6535) == ( + 6535, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 255), 153, 500) == ( - 500, + # Only warm channel enabled -> warmest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 255), 2000, 6535) == ( + 2000, 255, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 128), 153, 500) == ( - 500, + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 128), 2000, 6535) == ( + 2000, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 112, 143), 153, 500) == ( - 348, + # More warm than cold channel enabled -> warmer than mid point + assert color_util.rgbww_to_color_temperature((0, 0, 0, 112, 143), 2000, 6535) == ( + 2876, 255, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 56, 72), 153, 500) == ( - 348, + assert color_util.rgbww_to_color_temperature((0, 0, 0, 56, 72), 2000, 6535) == ( + 2872, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 0), 153, 500) == ( - 500, + # Both channels turned off -> warmest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 0), 2000, 6535) == ( + 2000, 0, ) @@ -547,33 +549,34 @@ def test_white_levels_to_color_temperature(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.while_levels_to_color_temperature( + # Only cold channel enabled -> coldest color temperature + assert color_util._white_levels_to_color_temperature(255, 0, 2000, 6535) == ( + 6535, 255, - 0, - 153, - 500, - ) == (153, 255) - assert color_util.while_levels_to_color_temperature(128, 0, 153, 500) == ( - 153, + ) + assert color_util._white_levels_to_color_temperature(128, 0, 2000, 6535) == ( + 6535, 128, ) - assert color_util.while_levels_to_color_temperature(0, 255, 153, 500) == ( - 500, + # Only warm channel enabled -> warmest color temperature + assert color_util._white_levels_to_color_temperature(0, 255, 2000, 6535) == ( + 2000, 255, ) - assert color_util.while_levels_to_color_temperature(0, 128, 153, 500) == ( - 500, + assert color_util._white_levels_to_color_temperature(0, 128, 2000, 6535) == ( + 2000, 128, ) - assert color_util.while_levels_to_color_temperature(112, 143, 153, 500) == ( - 348, + assert color_util._white_levels_to_color_temperature(112, 143, 2000, 6535) == ( + 2876, 255, ) - assert color_util.while_levels_to_color_temperature(56, 72, 153, 500) == ( - 348, + assert color_util._white_levels_to_color_temperature(56, 72, 2000, 6535) == ( + 2872, 128, ) - assert color_util.while_levels_to_color_temperature(0, 0, 153, 500) == ( - 500, + # Both channels turned off -> warmest color temperature + assert color_util._white_levels_to_color_temperature(0, 0, 2000, 6535) == ( + 2000, 0, ) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 79cd4e5e0dfdea..e902176bb35471 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +import time import pytest @@ -719,3 +720,8 @@ 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(): + """Test monotonic time coarse.""" + assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py index 769c5aaf80167a..f87b89df3f7693 100644 --- a/tests/util/test_pressure.py +++ b/tests/util/test_pressure.py @@ -118,7 +118,7 @@ def test_convert_from_inhg(): 101.59167 ) assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MMHG) == pytest.approx( - 762.002 + 762 ) @@ -126,23 +126,23 @@ def test_convert_from_mmhg(): """Test conversion from mmHg to other units.""" inhg = 30 assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PSI) == pytest.approx( - 0.580102 + 0.580103 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_KPA) == pytest.approx( - 3.99966 + 3.99967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_HPA) == pytest.approx( - 39.9966 + 39.9967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PA) == pytest.approx( - 3999.66 + 3999.67 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_MBAR) == pytest.approx( - 39.9966 + 39.9967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_CBAR) == pytest.approx( - 3.99966 + 3.99967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_INHG) == pytest.approx( - 1.181099 + 1.181102 ) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index ec839a6575c968..b2b99d6f8c006b 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -2,50 +2,15 @@ import pytest from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MICROGRAMS, - MASS_MILLIGRAMS, - MASS_OUNCES, - MASS_POUNDS, - POWER_KILO_WATT, - POWER_WATT, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - SPEED_FEET_PER_SECOND, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_KNOTS, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.util.unit_conversion import ( @@ -66,48 +31,50 @@ @pytest.mark.parametrize( "converter,valid_unit", [ - (DistanceConverter, LENGTH_KILOMETERS), - (DistanceConverter, LENGTH_METERS), - (DistanceConverter, LENGTH_CENTIMETERS), - (DistanceConverter, LENGTH_MILLIMETERS), - (DistanceConverter, LENGTH_MILES), - (DistanceConverter, LENGTH_YARD), - (DistanceConverter, LENGTH_FEET), - (DistanceConverter, LENGTH_INCHES), - (EnergyConverter, ENERGY_WATT_HOUR), - (EnergyConverter, ENERGY_KILO_WATT_HOUR), - (EnergyConverter, ENERGY_MEGA_WATT_HOUR), - (MassConverter, MASS_GRAMS), - (MassConverter, MASS_KILOGRAMS), - (MassConverter, MASS_MICROGRAMS), - (MassConverter, MASS_MILLIGRAMS), - (MassConverter, MASS_OUNCES), - (MassConverter, MASS_POUNDS), - (PowerConverter, POWER_WATT), - (PowerConverter, POWER_KILO_WATT), - (PressureConverter, PRESSURE_PA), - (PressureConverter, PRESSURE_HPA), - (PressureConverter, PRESSURE_MBAR), - (PressureConverter, PRESSURE_INHG), - (PressureConverter, PRESSURE_KPA), - (PressureConverter, PRESSURE_CBAR), - (PressureConverter, PRESSURE_MMHG), - (PressureConverter, PRESSURE_PSI), - (SpeedConverter, SPEED_FEET_PER_SECOND), - (SpeedConverter, SPEED_INCHES_PER_DAY), - (SpeedConverter, SPEED_INCHES_PER_HOUR), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR), - (SpeedConverter, SPEED_KNOTS), - (SpeedConverter, SPEED_METERS_PER_SECOND), - (SpeedConverter, SPEED_MILES_PER_HOUR), - (SpeedConverter, SPEED_MILLIMETERS_PER_DAY), - (TemperatureConverter, TEMP_CELSIUS), - (TemperatureConverter, TEMP_FAHRENHEIT), - (TemperatureConverter, TEMP_KELVIN), - (VolumeConverter, VOLUME_LITERS), - (VolumeConverter, VOLUME_MILLILITERS), - (VolumeConverter, VOLUME_GALLONS), - (VolumeConverter, VOLUME_FLUID_OUNCE), + (DistanceConverter, UnitOfLength.KILOMETERS), + (DistanceConverter, UnitOfLength.METERS), + (DistanceConverter, UnitOfLength.CENTIMETERS), + (DistanceConverter, UnitOfLength.MILLIMETERS), + (DistanceConverter, UnitOfLength.MILES), + (DistanceConverter, UnitOfLength.YARDS), + (DistanceConverter, UnitOfLength.FEET), + (DistanceConverter, UnitOfLength.INCHES), + (EnergyConverter, UnitOfEnergy.WATT_HOUR), + (EnergyConverter, UnitOfEnergy.KILO_WATT_HOUR), + (EnergyConverter, UnitOfEnergy.MEGA_WATT_HOUR), + (EnergyConverter, UnitOfEnergy.GIGA_JOULE), + (MassConverter, UnitOfMass.GRAMS), + (MassConverter, UnitOfMass.KILOGRAMS), + (MassConverter, UnitOfMass.MICROGRAMS), + (MassConverter, UnitOfMass.MILLIGRAMS), + (MassConverter, UnitOfMass.OUNCES), + (MassConverter, UnitOfMass.POUNDS), + (PowerConverter, UnitOfPower.WATT), + (PowerConverter, UnitOfPower.KILO_WATT), + (PressureConverter, UnitOfPressure.PA), + (PressureConverter, UnitOfPressure.HPA), + (PressureConverter, UnitOfPressure.MBAR), + (PressureConverter, UnitOfPressure.INHG), + (PressureConverter, UnitOfPressure.KPA), + (PressureConverter, UnitOfPressure.CBAR), + (PressureConverter, UnitOfPressure.MMHG), + (PressureConverter, UnitOfPressure.PSI), + (SpeedConverter, UnitOfVolumetricFlux.INCHES_PER_DAY), + (SpeedConverter, UnitOfVolumetricFlux.INCHES_PER_HOUR), + (SpeedConverter, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY), + (SpeedConverter, UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR), + (SpeedConverter, UnitOfSpeed.FEET_PER_SECOND), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR), + (SpeedConverter, UnitOfSpeed.KNOTS), + (SpeedConverter, UnitOfSpeed.METERS_PER_SECOND), + (SpeedConverter, UnitOfSpeed.MILES_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS), + (TemperatureConverter, UnitOfTemperature.FAHRENHEIT), + (TemperatureConverter, UnitOfTemperature.KELVIN), + (VolumeConverter, UnitOfVolume.LITERS), + (VolumeConverter, UnitOfVolume.MILLILITERS), + (VolumeConverter, UnitOfVolume.GALLONS), + (VolumeConverter, UnitOfVolume.FLUID_OUNCES), ], ) def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) -> None: @@ -118,16 +85,16 @@ def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) @pytest.mark.parametrize( "converter,valid_unit", [ - (DistanceConverter, LENGTH_KILOMETERS), - (EnergyConverter, ENERGY_KILO_WATT_HOUR), - (MassConverter, MASS_GRAMS), - (PowerConverter, POWER_WATT), - (PressureConverter, PRESSURE_PA), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR), - (TemperatureConverter, TEMP_CELSIUS), - (TemperatureConverter, TEMP_FAHRENHEIT), - (TemperatureConverter, TEMP_KELVIN), - (VolumeConverter, VOLUME_LITERS), + (DistanceConverter, UnitOfLength.KILOMETERS), + (EnergyConverter, UnitOfEnergy.KILO_WATT_HOUR), + (MassConverter, UnitOfMass.GRAMS), + (PowerConverter, UnitOfPower.WATT), + (PressureConverter, UnitOfPressure.PA), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS), + (TemperatureConverter, UnitOfTemperature.FAHRENHEIT), + (TemperatureConverter, UnitOfTemperature.KELVIN), + (VolumeConverter, UnitOfVolume.LITERS), ], ) def test_convert_invalid_unit( @@ -144,14 +111,14 @@ def test_convert_invalid_unit( @pytest.mark.parametrize( "converter,from_unit,to_unit", [ - (DistanceConverter, LENGTH_KILOMETERS, LENGTH_METERS), - (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), - (MassConverter, MASS_GRAMS, MASS_KILOGRAMS), - (PowerConverter, POWER_WATT, POWER_KILO_WATT), - (PressureConverter, PRESSURE_HPA, PRESSURE_INHG), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR), - (TemperatureConverter, TEMP_CELSIUS, TEMP_FAHRENHEIT), - (VolumeConverter, VOLUME_GALLONS, VOLUME_LITERS), + (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS), + (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR), + (MassConverter, UnitOfMass.GRAMS, UnitOfMass.KILOGRAMS), + (PowerConverter, UnitOfPower.WATT, UnitOfPower.KILO_WATT), + (PressureConverter, UnitOfPressure.HPA, UnitOfPressure.INHG), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT), + (VolumeConverter, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), ], ) def test_convert_nonnumeric_value( @@ -165,18 +132,33 @@ def test_convert_nonnumeric_value( @pytest.mark.parametrize( "converter,from_unit,to_unit,expected", [ - (DistanceConverter, LENGTH_KILOMETERS, LENGTH_METERS, 1 / 1000), - (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR, 1000), - (PowerConverter, POWER_WATT, POWER_KILO_WATT, 1000), - (PressureConverter, PRESSURE_HPA, PRESSURE_INHG, pytest.approx(33.86389)), + (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS, 1 / 1000), + (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + (PowerConverter, UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), + ( + PressureConverter, + UnitOfPressure.HPA, + UnitOfPressure.INHG, + pytest.approx(33.86389), + ), ( SpeedConverter, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, pytest.approx(1.609343), ), - (TemperatureConverter, TEMP_CELSIUS, TEMP_FAHRENHEIT, 1 / 1.8), - (VolumeConverter, VOLUME_GALLONS, VOLUME_LITERS, pytest.approx(0.264172)), + ( + TemperatureConverter, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + 1 / 1.8, + ), + ( + VolumeConverter, + UnitOfVolume.GALLONS, + UnitOfVolume.LITERS, + pytest.approx(0.264172), + ), ], ) def test_get_unit_ratio( @@ -189,62 +171,107 @@ def test_get_unit_ratio( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (5, LENGTH_MILES, pytest.approx(8.04672), LENGTH_KILOMETERS), - (5, LENGTH_MILES, pytest.approx(8046.72), LENGTH_METERS), - (5, LENGTH_MILES, pytest.approx(804672.0), LENGTH_CENTIMETERS), - (5, LENGTH_MILES, pytest.approx(8046720.0), LENGTH_MILLIMETERS), - (5, LENGTH_MILES, pytest.approx(8800.0), LENGTH_YARD), - (5, LENGTH_MILES, pytest.approx(26400.0008448), LENGTH_FEET), - (5, LENGTH_MILES, pytest.approx(316800.171072), LENGTH_INCHES), - (5, LENGTH_YARD, pytest.approx(0.0045720000000000005), LENGTH_KILOMETERS), - (5, LENGTH_YARD, pytest.approx(4.572), LENGTH_METERS), - (5, LENGTH_YARD, pytest.approx(457.2), LENGTH_CENTIMETERS), - (5, LENGTH_YARD, pytest.approx(4572), LENGTH_MILLIMETERS), - (5, LENGTH_YARD, pytest.approx(0.002840908212), LENGTH_MILES), - (5, LENGTH_YARD, pytest.approx(15.00000048), LENGTH_FEET), - (5, LENGTH_YARD, pytest.approx(180.0000972), LENGTH_INCHES), - (5000, LENGTH_FEET, pytest.approx(1.524), LENGTH_KILOMETERS), - (5000, LENGTH_FEET, pytest.approx(1524), LENGTH_METERS), - (5000, LENGTH_FEET, pytest.approx(152400.0), LENGTH_CENTIMETERS), - (5000, LENGTH_FEET, pytest.approx(1524000.0), LENGTH_MILLIMETERS), - (5000, LENGTH_FEET, pytest.approx(0.9469694040000001), LENGTH_MILES), - (5000, LENGTH_FEET, pytest.approx(1666.66667), LENGTH_YARD), - (5000, LENGTH_FEET, pytest.approx(60000.032400000004), LENGTH_INCHES), - (5000, LENGTH_INCHES, pytest.approx(0.127), LENGTH_KILOMETERS), - (5000, LENGTH_INCHES, pytest.approx(127.0), LENGTH_METERS), - (5000, LENGTH_INCHES, pytest.approx(12700.0), LENGTH_CENTIMETERS), - (5000, LENGTH_INCHES, pytest.approx(127000.0), LENGTH_MILLIMETERS), - (5000, LENGTH_INCHES, pytest.approx(0.078914117), LENGTH_MILES), - (5000, LENGTH_INCHES, pytest.approx(138.88889), LENGTH_YARD), - (5000, LENGTH_INCHES, pytest.approx(416.66668), LENGTH_FEET), - (5, LENGTH_KILOMETERS, pytest.approx(5000), LENGTH_METERS), - (5, LENGTH_KILOMETERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5, LENGTH_KILOMETERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (5, LENGTH_KILOMETERS, pytest.approx(3.106855), LENGTH_MILES), - (5, LENGTH_KILOMETERS, pytest.approx(5468.066), LENGTH_YARD), - (5, LENGTH_KILOMETERS, pytest.approx(16404.2), LENGTH_FEET), - (5, LENGTH_KILOMETERS, pytest.approx(196850.5), LENGTH_INCHES), - (5000, LENGTH_METERS, pytest.approx(5), LENGTH_KILOMETERS), - (5000, LENGTH_METERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5000, LENGTH_METERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (5000, LENGTH_METERS, pytest.approx(3.106855), LENGTH_MILES), - (5000, LENGTH_METERS, pytest.approx(5468.066), LENGTH_YARD), - (5000, LENGTH_METERS, pytest.approx(16404.2), LENGTH_FEET), - (5000, LENGTH_METERS, pytest.approx(196850.5), LENGTH_INCHES), - (500000, LENGTH_CENTIMETERS, pytest.approx(5), LENGTH_KILOMETERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(5000), LENGTH_METERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(3.106855), LENGTH_MILES), - (500000, LENGTH_CENTIMETERS, pytest.approx(5468.066), LENGTH_YARD), - (500000, LENGTH_CENTIMETERS, pytest.approx(16404.2), LENGTH_FEET), - (500000, LENGTH_CENTIMETERS, pytest.approx(196850.5), LENGTH_INCHES), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5), LENGTH_KILOMETERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5000), LENGTH_METERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(3.106855), LENGTH_MILES), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5468.066), LENGTH_YARD), - (5000000, LENGTH_MILLIMETERS, pytest.approx(16404.2), LENGTH_FEET), - (5000000, LENGTH_MILLIMETERS, pytest.approx(196850.5), LENGTH_INCHES), + (5, UnitOfLength.MILES, pytest.approx(8.04672), UnitOfLength.KILOMETERS), + (5, UnitOfLength.MILES, pytest.approx(8046.72), UnitOfLength.METERS), + (5, UnitOfLength.MILES, pytest.approx(804672.0), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.MILES, pytest.approx(8046720.0), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.MILES, pytest.approx(8800.0), UnitOfLength.YARDS), + (5, UnitOfLength.MILES, pytest.approx(26400.0008448), UnitOfLength.FEET), + (5, UnitOfLength.MILES, pytest.approx(316800.171072), UnitOfLength.INCHES), + ( + 5, + UnitOfLength.YARDS, + pytest.approx(0.0045720000000000005), + UnitOfLength.KILOMETERS, + ), + (5, UnitOfLength.YARDS, pytest.approx(4.572), UnitOfLength.METERS), + (5, UnitOfLength.YARDS, pytest.approx(457.2), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.YARDS, pytest.approx(4572), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.YARDS, pytest.approx(0.002840908212), UnitOfLength.MILES), + (5, UnitOfLength.YARDS, pytest.approx(15.00000048), UnitOfLength.FEET), + (5, UnitOfLength.YARDS, pytest.approx(180.0000972), UnitOfLength.INCHES), + (5000, UnitOfLength.FEET, pytest.approx(1.524), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.FEET, pytest.approx(1524), UnitOfLength.METERS), + (5000, UnitOfLength.FEET, pytest.approx(152400.0), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.FEET, pytest.approx(1524000.0), UnitOfLength.MILLIMETERS), + ( + 5000, + UnitOfLength.FEET, + pytest.approx(0.9469694040000001), + UnitOfLength.MILES, + ), + (5000, UnitOfLength.FEET, pytest.approx(1666.66667), UnitOfLength.YARDS), + ( + 5000, + UnitOfLength.FEET, + pytest.approx(60000.032400000004), + UnitOfLength.INCHES, + ), + (5000, UnitOfLength.INCHES, pytest.approx(0.127), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(127.0), UnitOfLength.METERS), + (5000, UnitOfLength.INCHES, pytest.approx(12700.0), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(127000.0), UnitOfLength.MILLIMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(0.078914117), UnitOfLength.MILES), + (5000, UnitOfLength.INCHES, pytest.approx(138.88889), UnitOfLength.YARDS), + (5000, UnitOfLength.INCHES, pytest.approx(416.66668), UnitOfLength.FEET), + (5, UnitOfLength.KILOMETERS, pytest.approx(5000), UnitOfLength.METERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(500000), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(5000000), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(3.106855), UnitOfLength.MILES), + (5, UnitOfLength.KILOMETERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (5, UnitOfLength.KILOMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + (5, UnitOfLength.KILOMETERS, pytest.approx(196850.5), UnitOfLength.INCHES), + (5000, UnitOfLength.METERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.METERS, pytest.approx(500000), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.METERS, pytest.approx(5000000), UnitOfLength.MILLIMETERS), + (5000, UnitOfLength.METERS, pytest.approx(3.106855), UnitOfLength.MILES), + (5000, UnitOfLength.METERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (5000, UnitOfLength.METERS, pytest.approx(16404.2), UnitOfLength.FEET), + (5000, UnitOfLength.METERS, pytest.approx(196850.5), UnitOfLength.INCHES), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5000), UnitOfLength.METERS), + ( + 500000, + UnitOfLength.CENTIMETERS, + pytest.approx(5000000), + UnitOfLength.MILLIMETERS, + ), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(3.106855), UnitOfLength.MILES), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + ( + 500000, + UnitOfLength.CENTIMETERS, + pytest.approx(196850.5), + UnitOfLength.INCHES, + ), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(5000), UnitOfLength.METERS), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(500000), + UnitOfLength.CENTIMETERS, + ), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(3.106855), + UnitOfLength.MILES, + ), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(5468.066), + UnitOfLength.YARDS, + ), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(196850.5), + UnitOfLength.INCHES, + ), ], ) def test_distance_convert( @@ -260,12 +287,14 @@ def test_distance_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, ENERGY_WATT_HOUR, 0.01, ENERGY_KILO_WATT_HOUR), - (10, ENERGY_WATT_HOUR, 0.00001, ENERGY_MEGA_WATT_HOUR), - (10, ENERGY_KILO_WATT_HOUR, 10000, ENERGY_WATT_HOUR), - (10, ENERGY_KILO_WATT_HOUR, 0.01, ENERGY_MEGA_WATT_HOUR), - (10, ENERGY_MEGA_WATT_HOUR, 10000000, ENERGY_WATT_HOUR), - (10, ENERGY_MEGA_WATT_HOUR, 10000, ENERGY_KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.KILO_WATT_HOUR, 10000, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.KILO_WATT_HOUR, 0.01, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000000, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 10000 / 3.6, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 10 / 3.6, UnitOfEnergy.MEGA_WATT_HOUR), ], ) def test_energy_convert( @@ -281,36 +310,41 @@ def test_energy_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, MASS_KILOGRAMS, 10000, MASS_GRAMS), - (10, MASS_KILOGRAMS, 10000000, MASS_MILLIGRAMS), - (10, MASS_KILOGRAMS, 10000000000, MASS_MICROGRAMS), - (10, MASS_KILOGRAMS, pytest.approx(352.73961), MASS_OUNCES), - (10, MASS_KILOGRAMS, pytest.approx(22.046226), MASS_POUNDS), - (10, MASS_GRAMS, 0.01, MASS_KILOGRAMS), - (10, MASS_GRAMS, 10000, MASS_MILLIGRAMS), - (10, MASS_GRAMS, 10000000, MASS_MICROGRAMS), - (10, MASS_GRAMS, pytest.approx(0.35273961), MASS_OUNCES), - (10, MASS_GRAMS, pytest.approx(0.022046226), MASS_POUNDS), - (10, MASS_MILLIGRAMS, 0.00001, MASS_KILOGRAMS), - (10, MASS_MILLIGRAMS, 0.01, MASS_GRAMS), - (10, MASS_MILLIGRAMS, 10000, MASS_MICROGRAMS), - (10, MASS_MILLIGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), - (10, MASS_MILLIGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), - (10000, MASS_MICROGRAMS, 0.00001, MASS_KILOGRAMS), - (10000, MASS_MICROGRAMS, 0.01, MASS_GRAMS), - (10000, MASS_MICROGRAMS, 10, MASS_MILLIGRAMS), - (10000, MASS_MICROGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), - (10000, MASS_MICROGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), - (1, MASS_POUNDS, 0.45359237, MASS_KILOGRAMS), - (1, MASS_POUNDS, 453.59237, MASS_GRAMS), - (1, MASS_POUNDS, 453592.37, MASS_MILLIGRAMS), - (1, MASS_POUNDS, 453592370, MASS_MICROGRAMS), - (1, MASS_POUNDS, 16, MASS_OUNCES), - (16, MASS_OUNCES, 0.45359237, MASS_KILOGRAMS), - (16, MASS_OUNCES, 453.59237, MASS_GRAMS), - (16, MASS_OUNCES, 453592.37, MASS_MILLIGRAMS), - (16, MASS_OUNCES, 453592370, MASS_MICROGRAMS), - (16, MASS_OUNCES, 1, MASS_POUNDS), + (10, UnitOfMass.KILOGRAMS, 10000, UnitOfMass.GRAMS), + (10, UnitOfMass.KILOGRAMS, 10000000, UnitOfMass.MILLIGRAMS), + (10, UnitOfMass.KILOGRAMS, 10000000000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.KILOGRAMS, pytest.approx(352.73961), UnitOfMass.OUNCES), + (10, UnitOfMass.KILOGRAMS, pytest.approx(22.046226), UnitOfMass.POUNDS), + (10, UnitOfMass.GRAMS, 0.01, UnitOfMass.KILOGRAMS), + (10, UnitOfMass.GRAMS, 10000, UnitOfMass.MILLIGRAMS), + (10, UnitOfMass.GRAMS, 10000000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.GRAMS, pytest.approx(0.35273961), UnitOfMass.OUNCES), + (10, UnitOfMass.GRAMS, pytest.approx(0.022046226), UnitOfMass.POUNDS), + (10, UnitOfMass.MILLIGRAMS, 0.00001, UnitOfMass.KILOGRAMS), + (10, UnitOfMass.MILLIGRAMS, 0.01, UnitOfMass.GRAMS), + (10, UnitOfMass.MILLIGRAMS, 10000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.MILLIGRAMS, pytest.approx(0.00035273961), UnitOfMass.OUNCES), + (10, UnitOfMass.MILLIGRAMS, pytest.approx(0.000022046226), UnitOfMass.POUNDS), + (10000, UnitOfMass.MICROGRAMS, 0.00001, UnitOfMass.KILOGRAMS), + (10000, UnitOfMass.MICROGRAMS, 0.01, UnitOfMass.GRAMS), + (10000, UnitOfMass.MICROGRAMS, 10, UnitOfMass.MILLIGRAMS), + (10000, UnitOfMass.MICROGRAMS, pytest.approx(0.00035273961), UnitOfMass.OUNCES), + ( + 10000, + UnitOfMass.MICROGRAMS, + pytest.approx(0.000022046226), + UnitOfMass.POUNDS, + ), + (1, UnitOfMass.POUNDS, 0.45359237, UnitOfMass.KILOGRAMS), + (1, UnitOfMass.POUNDS, 453.59237, UnitOfMass.GRAMS), + (1, UnitOfMass.POUNDS, 453592.37, UnitOfMass.MILLIGRAMS), + (1, UnitOfMass.POUNDS, 453592370, UnitOfMass.MICROGRAMS), + (1, UnitOfMass.POUNDS, 16, UnitOfMass.OUNCES), + (16, UnitOfMass.OUNCES, 0.45359237, UnitOfMass.KILOGRAMS), + (16, UnitOfMass.OUNCES, 453.59237, UnitOfMass.GRAMS), + (16, UnitOfMass.OUNCES, 453592.37, UnitOfMass.MILLIGRAMS), + (16, UnitOfMass.OUNCES, 453592370, UnitOfMass.MICROGRAMS), + (16, UnitOfMass.OUNCES, 1, UnitOfMass.POUNDS), ], ) def test_mass_convert( @@ -326,8 +360,8 @@ def test_mass_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, POWER_KILO_WATT, 10000, POWER_WATT), - (10, POWER_WATT, 0.01, POWER_KILO_WATT), + (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), + (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), ], ) def test_power_convert( @@ -343,32 +377,32 @@ def test_power_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (1000, PRESSURE_HPA, pytest.approx(14.5037743897), PRESSURE_PSI), - (1000, PRESSURE_HPA, pytest.approx(29.5299801647), PRESSURE_INHG), - (1000, PRESSURE_HPA, pytest.approx(100000), PRESSURE_PA), - (1000, PRESSURE_HPA, pytest.approx(100), PRESSURE_KPA), - (1000, PRESSURE_HPA, pytest.approx(1000), PRESSURE_MBAR), - (1000, PRESSURE_HPA, pytest.approx(100), PRESSURE_CBAR), - (100, PRESSURE_KPA, pytest.approx(14.5037743897), PRESSURE_PSI), - (100, PRESSURE_KPA, pytest.approx(29.5299801647), PRESSURE_INHG), - (100, PRESSURE_KPA, pytest.approx(100000), PRESSURE_PA), - (100, PRESSURE_KPA, pytest.approx(1000), PRESSURE_HPA), - (100, PRESSURE_KPA, pytest.approx(1000), PRESSURE_MBAR), - (100, PRESSURE_KPA, pytest.approx(100), PRESSURE_CBAR), - (30, PRESSURE_INHG, pytest.approx(14.7346266155), PRESSURE_PSI), - (30, PRESSURE_INHG, pytest.approx(101.59167), PRESSURE_KPA), - (30, PRESSURE_INHG, pytest.approx(1015.9167), PRESSURE_HPA), - (30, PRESSURE_INHG, pytest.approx(101591.67), PRESSURE_PA), - (30, PRESSURE_INHG, pytest.approx(1015.9167), PRESSURE_MBAR), - (30, PRESSURE_INHG, pytest.approx(101.59167), PRESSURE_CBAR), - (30, PRESSURE_INHG, pytest.approx(762.002), PRESSURE_MMHG), - (30, PRESSURE_MMHG, pytest.approx(0.580102), PRESSURE_PSI), - (30, PRESSURE_MMHG, pytest.approx(3.99966), PRESSURE_KPA), - (30, PRESSURE_MMHG, pytest.approx(39.9966), PRESSURE_HPA), - (30, PRESSURE_MMHG, pytest.approx(3999.66), PRESSURE_PA), - (30, PRESSURE_MMHG, pytest.approx(39.9966), PRESSURE_MBAR), - (30, PRESSURE_MMHG, pytest.approx(3.99966), PRESSURE_CBAR), - (30, PRESSURE_MMHG, pytest.approx(1.181099), PRESSURE_INHG), + (1000, UnitOfPressure.HPA, pytest.approx(14.5037743897), UnitOfPressure.PSI), + (1000, UnitOfPressure.HPA, pytest.approx(29.5299801647), UnitOfPressure.INHG), + (1000, UnitOfPressure.HPA, pytest.approx(100000), UnitOfPressure.PA), + (1000, UnitOfPressure.HPA, pytest.approx(100), UnitOfPressure.KPA), + (1000, UnitOfPressure.HPA, pytest.approx(1000), UnitOfPressure.MBAR), + (1000, UnitOfPressure.HPA, pytest.approx(100), UnitOfPressure.CBAR), + (100, UnitOfPressure.KPA, pytest.approx(14.5037743897), UnitOfPressure.PSI), + (100, UnitOfPressure.KPA, pytest.approx(29.5299801647), UnitOfPressure.INHG), + (100, UnitOfPressure.KPA, pytest.approx(100000), UnitOfPressure.PA), + (100, UnitOfPressure.KPA, pytest.approx(1000), UnitOfPressure.HPA), + (100, UnitOfPressure.KPA, pytest.approx(1000), UnitOfPressure.MBAR), + (100, UnitOfPressure.KPA, pytest.approx(100), UnitOfPressure.CBAR), + (30, UnitOfPressure.INHG, pytest.approx(14.7346266155), UnitOfPressure.PSI), + (30, UnitOfPressure.INHG, pytest.approx(101.59167), UnitOfPressure.KPA), + (30, UnitOfPressure.INHG, pytest.approx(1015.9167), UnitOfPressure.HPA), + (30, UnitOfPressure.INHG, pytest.approx(101591.67), UnitOfPressure.PA), + (30, UnitOfPressure.INHG, pytest.approx(1015.9167), UnitOfPressure.MBAR), + (30, UnitOfPressure.INHG, pytest.approx(101.59167), UnitOfPressure.CBAR), + (30, UnitOfPressure.INHG, pytest.approx(762), UnitOfPressure.MMHG), + (30, UnitOfPressure.MMHG, pytest.approx(0.580103), UnitOfPressure.PSI), + (30, UnitOfPressure.MMHG, pytest.approx(3.99967), UnitOfPressure.KPA), + (30, UnitOfPressure.MMHG, pytest.approx(39.9967), UnitOfPressure.HPA), + (30, UnitOfPressure.MMHG, pytest.approx(3999.67), UnitOfPressure.PA), + (30, UnitOfPressure.MMHG, pytest.approx(39.9967), UnitOfPressure.MBAR), + (30, UnitOfPressure.MMHG, pytest.approx(3.99967), UnitOfPressure.CBAR), + (30, UnitOfPressure.MMHG, pytest.approx(1.181102), UnitOfPressure.INHG), ], ) def test_pressure_convert( @@ -385,28 +419,65 @@ def test_pressure_convert( "value,from_unit,expected,to_unit", [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h - (5, SPEED_KILOMETERS_PER_HOUR, pytest.approx(3.106856), SPEED_MILES_PER_HOUR), + ( + 5, + UnitOfSpeed.KILOMETERS_PER_HOUR, + pytest.approx(3.106856), + UnitOfSpeed.MILES_PER_HOUR, + ), # 5 mi/h * 1.609 km/mi = 8.04672 km/h - (5, SPEED_MILES_PER_HOUR, 8.04672, SPEED_KILOMETERS_PER_HOUR), + (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), # 5 in/day * 25.4 mm/in = 127 mm/day - (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.INCHES_PER_DAY, + 127, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + ), # 5 mm/day / 25.4 mm/in = 0.19685 in/day - (5, SPEED_MILLIMETERS_PER_DAY, pytest.approx(0.1968504), SPEED_INCHES_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + pytest.approx(0.1968504), + UnitOfVolumetricFlux.INCHES_PER_DAY, + ), + # 48 mm/day = 2 mm/h + ( + 48, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + pytest.approx(2), + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + ), # 5 in/hr * 24 hr/day = 3048 mm/day - (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.INCHES_PER_HOUR, + 3048, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + ), # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 - (5, SPEED_METERS_PER_SECOND, pytest.approx(708661.42), SPEED_INCHES_PER_HOUR), + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + pytest.approx(708661.42), + UnitOfVolumetricFlux.INCHES_PER_HOUR, + ), # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s ( 5000, - SPEED_INCHES_PER_HOUR, + UnitOfVolumetricFlux.INCHES_PER_HOUR, pytest.approx(0.0352778), - SPEED_METERS_PER_SECOND, + UnitOfSpeed.METERS_PER_SECOND, ), # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s - (5, SPEED_KNOTS, pytest.approx(2.57222), SPEED_METERS_PER_SECOND), + (5, UnitOfSpeed.KNOTS, pytest.approx(2.57222), UnitOfSpeed.METERS_PER_SECOND), # 5 ft/s * 0.3048 m/ft = 1.524 m/s - (5, SPEED_FEET_PER_SECOND, pytest.approx(1.524), SPEED_METERS_PER_SECOND), + ( + 5, + UnitOfSpeed.FEET_PER_SECOND, + pytest.approx(1.524), + UnitOfSpeed.METERS_PER_SECOND, + ), ], ) def test_speed_convert( @@ -422,12 +493,32 @@ def test_speed_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (100, TEMP_CELSIUS, 212, TEMP_FAHRENHEIT), - (100, TEMP_CELSIUS, 373.15, TEMP_KELVIN), - (100, TEMP_FAHRENHEIT, pytest.approx(37.77777777777778), TEMP_CELSIUS), - (100, TEMP_FAHRENHEIT, pytest.approx(310.92777777777775), TEMP_KELVIN), - (100, TEMP_KELVIN, pytest.approx(-173.15), TEMP_CELSIUS), - (100, TEMP_KELVIN, pytest.approx(-279.66999999999996), TEMP_FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 212, UnitOfTemperature.FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 373.15, UnitOfTemperature.KELVIN), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(37.77777777777778), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(310.92777777777775), + UnitOfTemperature.KELVIN, + ), + ( + 100, + UnitOfTemperature.KELVIN, + pytest.approx(-173.15), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.KELVIN, + pytest.approx(-279.66999999999996), + UnitOfTemperature.FAHRENHEIT, + ), ], ) def test_temperature_convert( @@ -440,12 +531,22 @@ def test_temperature_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (100, TEMP_CELSIUS, 180, TEMP_FAHRENHEIT), - (100, TEMP_CELSIUS, 100, TEMP_KELVIN), - (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_CELSIUS), - (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_KELVIN), - (100, TEMP_KELVIN, 100, TEMP_CELSIUS), - (100, TEMP_KELVIN, 180, TEMP_FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 180, UnitOfTemperature.FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 100, UnitOfTemperature.KELVIN), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(55.55555555555556), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(55.55555555555556), + UnitOfTemperature.KELVIN, + ), + (100, UnitOfTemperature.KELVIN, 100, UnitOfTemperature.CELSIUS), + (100, UnitOfTemperature.KELVIN, 180, UnitOfTemperature.FAHRENHEIT), ], ) def test_temperature_convert_with_interval( @@ -458,40 +559,110 @@ def test_temperature_convert_with_interval( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (5, VOLUME_LITERS, pytest.approx(1.32086), VOLUME_GALLONS), - (5, VOLUME_GALLONS, pytest.approx(18.92706), VOLUME_LITERS), - (5, VOLUME_CUBIC_METERS, pytest.approx(176.5733335), VOLUME_CUBIC_FEET), - (500, VOLUME_CUBIC_FEET, pytest.approx(14.1584233), VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14.1584233), VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(478753.2467), VOLUME_FLUID_OUNCE), - (500, VOLUME_CUBIC_FEET, pytest.approx(3740.25974), VOLUME_GALLONS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14158.42329599), VOLUME_LITERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14158423.29599), VOLUME_MILLILITERS), - (500, VOLUME_CUBIC_METERS, 500, VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_METERS, pytest.approx(16907011.35), VOLUME_FLUID_OUNCE), - (500, VOLUME_CUBIC_METERS, pytest.approx(132086.02617), VOLUME_GALLONS), - (500, VOLUME_CUBIC_METERS, 500000, VOLUME_LITERS), - (500, VOLUME_CUBIC_METERS, 500000000, VOLUME_MILLILITERS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(0.52218967), VOLUME_CUBIC_FEET), - (500, VOLUME_FLUID_OUNCE, pytest.approx(0.014786764), VOLUME_CUBIC_METERS), - (500, VOLUME_FLUID_OUNCE, 3.90625, VOLUME_GALLONS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(14.786764), VOLUME_LITERS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(14786.764), VOLUME_MILLILITERS), - (500, VOLUME_GALLONS, pytest.approx(66.84027), VOLUME_CUBIC_FEET), - (500, VOLUME_GALLONS, pytest.approx(1.892706), VOLUME_CUBIC_METERS), - (500, VOLUME_GALLONS, 64000, VOLUME_FLUID_OUNCE), - (500, VOLUME_GALLONS, pytest.approx(1892.70589), VOLUME_LITERS), - (500, VOLUME_GALLONS, pytest.approx(1892705.89), VOLUME_MILLILITERS), - (500, VOLUME_LITERS, pytest.approx(17.65733), VOLUME_CUBIC_FEET), - (500, VOLUME_LITERS, 0.5, VOLUME_CUBIC_METERS), - (500, VOLUME_LITERS, pytest.approx(16907.011), VOLUME_FLUID_OUNCE), - (500, VOLUME_LITERS, pytest.approx(132.086), VOLUME_GALLONS), - (500, VOLUME_LITERS, 500000, VOLUME_MILLILITERS), - (500, VOLUME_MILLILITERS, pytest.approx(0.01765733), VOLUME_CUBIC_FEET), - (500, VOLUME_MILLILITERS, 0.0005, VOLUME_CUBIC_METERS), - (500, VOLUME_MILLILITERS, pytest.approx(16.907), VOLUME_FLUID_OUNCE), - (500, VOLUME_MILLILITERS, pytest.approx(0.132086), VOLUME_GALLONS), - (500, VOLUME_MILLILITERS, 0.5, VOLUME_LITERS), + (5, UnitOfVolume.LITERS, pytest.approx(1.32086), UnitOfVolume.GALLONS), + (5, UnitOfVolume.GALLONS, pytest.approx(18.92706), UnitOfVolume.LITERS), + ( + 5, + UnitOfVolume.CUBIC_METERS, + pytest.approx(176.5733335), + UnitOfVolume.CUBIC_FEET, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14.1584233), + UnitOfVolume.CUBIC_METERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14.1584233), + UnitOfVolume.CUBIC_METERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(478753.2467), + UnitOfVolume.FLUID_OUNCES, + ), + (500, UnitOfVolume.CUBIC_FEET, pytest.approx(3740.25974), UnitOfVolume.GALLONS), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14158.42329599), + UnitOfVolume.LITERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14158423.29599), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.CUBIC_METERS, 500, UnitOfVolume.CUBIC_METERS), + ( + 500, + UnitOfVolume.CUBIC_METERS, + pytest.approx(16907011.35), + UnitOfVolume.FLUID_OUNCES, + ), + ( + 500, + UnitOfVolume.CUBIC_METERS, + pytest.approx(132086.02617), + UnitOfVolume.GALLONS, + ), + (500, UnitOfVolume.CUBIC_METERS, 500000, UnitOfVolume.LITERS), + (500, UnitOfVolume.CUBIC_METERS, 500000000, UnitOfVolume.MILLILITERS), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(0.52218967), + UnitOfVolume.CUBIC_FEET, + ), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(0.014786764), + UnitOfVolume.CUBIC_METERS, + ), + (500, UnitOfVolume.FLUID_OUNCES, 3.90625, UnitOfVolume.GALLONS), + (500, UnitOfVolume.FLUID_OUNCES, pytest.approx(14.786764), UnitOfVolume.LITERS), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(14786.764), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.GALLONS, pytest.approx(66.84027), UnitOfVolume.CUBIC_FEET), + (500, UnitOfVolume.GALLONS, pytest.approx(1.892706), UnitOfVolume.CUBIC_METERS), + (500, UnitOfVolume.GALLONS, 64000, UnitOfVolume.FLUID_OUNCES), + (500, UnitOfVolume.GALLONS, pytest.approx(1892.70589), UnitOfVolume.LITERS), + ( + 500, + UnitOfVolume.GALLONS, + pytest.approx(1892705.89), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.LITERS, pytest.approx(17.65733), UnitOfVolume.CUBIC_FEET), + (500, UnitOfVolume.LITERS, 0.5, UnitOfVolume.CUBIC_METERS), + (500, UnitOfVolume.LITERS, pytest.approx(16907.011), UnitOfVolume.FLUID_OUNCES), + (500, UnitOfVolume.LITERS, pytest.approx(132.086), UnitOfVolume.GALLONS), + (500, UnitOfVolume.LITERS, 500000, UnitOfVolume.MILLILITERS), + ( + 500, + UnitOfVolume.MILLILITERS, + pytest.approx(0.01765733), + UnitOfVolume.CUBIC_FEET, + ), + (500, UnitOfVolume.MILLILITERS, 0.0005, UnitOfVolume.CUBIC_METERS), + ( + 500, + UnitOfVolume.MILLILITERS, + pytest.approx(16.907), + UnitOfVolume.FLUID_OUNCES, + ), + (500, UnitOfVolume.MILLILITERS, pytest.approx(0.132086), UnitOfVolume.GALLONS), + (500, UnitOfVolume.MILLILITERS, 0.5, UnitOfVolume.LITERS), ], ) def test_volume_convert( diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index a284fd100178b5..03385196cabad2 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -1,25 +1,35 @@ """Test the unit system helper.""" +from __future__ import annotations + import pytest +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ACCUMULATED_PRECIPITATION, LENGTH, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILLIMETERS, MASS, - MASS_GRAMS, PRESSURE, - PRESSURE_PA, - SPEED_METERS_PER_SECOND, - TEMP_CELSIUS, TEMPERATURE, VOLUME, - VOLUME_LITERS, WIND_SPEED, + UnitOfLength, + UnitOfMass, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from homeassistant.util.unit_system import ( + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_METRIC, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + IMPERIAL_SYSTEM, + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, + get_unit_system, +) SYSTEM_NAME = "TEST" INVALID_UNIT = "INVALID" @@ -30,114 +40,121 @@ def test_invalid_units(): with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - INVALID_UNIT, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=INVALID_UNIT, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - INVALID_UNIT, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=INVALID_UNIT, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - INVALID_UNIT, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=INVALID_UNIT, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - INVALID_UNIT, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=INVALID_UNIT, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - INVALID_UNIT, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=INVALID_UNIT, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - INVALID_UNIT, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=INVALID_UNIT, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - INVALID_UNIT, + accumulated_precipitation=INVALID_UNIT, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) def test_invalid_value(): """Test no conversion happens if value is non-numeric.""" with pytest.raises(TypeError): - METRIC_SYSTEM.length("25a", LENGTH_KILOMETERS) + METRIC_SYSTEM.length("25a", UnitOfLength.KILOMETERS) with pytest.raises(TypeError): - METRIC_SYSTEM.temperature("50K", TEMP_CELSIUS) + METRIC_SYSTEM.temperature("50K", UnitOfTemperature.CELSIUS) with pytest.raises(TypeError): - METRIC_SYSTEM.wind_speed("50km/h", SPEED_METERS_PER_SECOND) + METRIC_SYSTEM.wind_speed("50km/h", UnitOfSpeed.METERS_PER_SECOND) with pytest.raises(TypeError): - METRIC_SYSTEM.volume("50L", VOLUME_LITERS) + METRIC_SYSTEM.volume("50L", UnitOfVolume.LITERS) with pytest.raises(TypeError): - METRIC_SYSTEM.pressure("50Pa", PRESSURE_PA) + METRIC_SYSTEM.pressure("50Pa", UnitOfPressure.PA) with pytest.raises(TypeError): - METRIC_SYSTEM.accumulated_precipitation("50mm", LENGTH_MILLIMETERS) + METRIC_SYSTEM.accumulated_precipitation("50mm", UnitOfLength.MILLIMETERS) def test_as_dict(): """Test that the as_dict() method returns the expected dictionary.""" expected = { - LENGTH: LENGTH_KILOMETERS, - WIND_SPEED: SPEED_METERS_PER_SECOND, - TEMPERATURE: TEMP_CELSIUS, - VOLUME: VOLUME_LITERS, - MASS: MASS_GRAMS, - PRESSURE: PRESSURE_PA, - ACCUMULATED_PRECIPITATION: LENGTH_MILLIMETERS, + LENGTH: UnitOfLength.KILOMETERS, + WIND_SPEED: UnitOfSpeed.METERS_PER_SECOND, + TEMPERATURE: UnitOfTemperature.CELSIUS, + VOLUME: UnitOfVolume.LITERS, + MASS: UnitOfMass.GRAMS, + PRESSURE: UnitOfPressure.PA, + ACCUMULATED_PRECIPITATION: UnitOfLength.MILLIMETERS, } assert expected == METRIC_SYSTEM.as_dict() @@ -285,16 +302,186 @@ def test_accumulated_precipitation_to_imperial(): def test_properties(): """Test the unit properties are returned as expected.""" - assert METRIC_SYSTEM.length_unit == LENGTH_KILOMETERS - assert METRIC_SYSTEM.wind_speed_unit == SPEED_METERS_PER_SECOND - assert METRIC_SYSTEM.temperature_unit == TEMP_CELSIUS - assert METRIC_SYSTEM.mass_unit == MASS_GRAMS - assert METRIC_SYSTEM.volume_unit == VOLUME_LITERS - assert METRIC_SYSTEM.pressure_unit == PRESSURE_PA - assert METRIC_SYSTEM.accumulated_precipitation_unit == LENGTH_MILLIMETERS + assert METRIC_SYSTEM.length_unit == UnitOfLength.KILOMETERS + assert METRIC_SYSTEM.wind_speed_unit == UnitOfSpeed.METERS_PER_SECOND + assert METRIC_SYSTEM.temperature_unit == UnitOfTemperature.CELSIUS + assert METRIC_SYSTEM.mass_unit == UnitOfMass.GRAMS + assert METRIC_SYSTEM.volume_unit == UnitOfVolume.LITERS + assert METRIC_SYSTEM.pressure_unit == UnitOfPressure.PA + assert METRIC_SYSTEM.accumulated_precipitation_unit == UnitOfLength.MILLIMETERS + + +@pytest.mark.parametrize( + "unit_system, expected_flag", + [ + (METRIC_SYSTEM, True), + (IMPERIAL_SYSTEM, False), + ], +) +def test_is_metric( + caplog: pytest.LogCaptureFixture, unit_system: UnitSystem, expected_flag: bool +): + """Test the is metric flag.""" + assert unit_system.is_metric == expected_flag + assert ( + "Detected code that accesses the `is_metric` property of the unit system." + in caplog.text + ) -def test_is_metric(): - """Test the is metric flag.""" - assert METRIC_SYSTEM.is_metric - assert not IMPERIAL_SYSTEM.is_metric +@pytest.mark.parametrize( + "unit_system, expected_name, expected_private_name", + [ + (METRIC_SYSTEM, _CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_METRIC), + (IMPERIAL_SYSTEM, _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_US_CUSTOMARY), + ( + US_CUSTOMARY_SYSTEM, + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + ), + ], +) +def test_deprecated_name( + caplog: pytest.LogCaptureFixture, + unit_system: UnitSystem, + expected_name: str, + expected_private_name: str, +) -> None: + """Test the name is deprecated.""" + assert unit_system.name == expected_name + assert unit_system._name == expected_private_name + assert ( + "Detected code that accesses the `name` property of the unit system." + in caplog.text + ) + + +@pytest.mark.parametrize( + "key, expected_system", + [ + (_CONF_UNIT_SYSTEM_METRIC, METRIC_SYSTEM), + (_CONF_UNIT_SYSTEM_US_CUSTOMARY, US_CUSTOMARY_SYSTEM), + ], +) +def test_get_unit_system(key: str, expected_system: UnitSystem) -> None: + """Test get_unit_system.""" + assert get_unit_system(key) is expected_system + + +@pytest.mark.parametrize( + "key", [None, "", "invalid_custom", _CONF_UNIT_SYSTEM_IMPERIAL] +) +def test_get_unit_system_invalid(key: str) -> None: + """Test get_unit_system with an invalid key.""" + with pytest.raises(ValueError, match=f"`{key}` is not a valid unit system key"): + _ = get_unit_system(key) + + +@pytest.mark.parametrize( + "device_class, original_unit, state_unit", + ( + # Test distance conversion + (SensorDeviceClass.DISTANCE, UnitOfLength.FEET, UnitOfLength.METERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.INCHES, UnitOfLength.MILLIMETERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILES, UnitOfLength.KILOMETERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.YARDS, UnitOfLength.METERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, None), + (SensorDeviceClass.DISTANCE, "very_long", None), + # Test gas meter conversion + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.GAS, "very_much", None), + # Test speed conversion + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.METERS_PER_SECOND, None), + (SensorDeviceClass.SPEED, "very_fast", None), + # Test volume conversion + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, UnitOfVolume.MILLILITERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, None), + (SensorDeviceClass.VOLUME, "very_much", None), + # Test water meter conversion + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), + (SensorDeviceClass.WATER, "very_much", None), + ), +) +def test_get_metric_converted_unit_( + device_class: SensorDeviceClass, + original_unit: str, + state_unit: str | None, +) -> None: + """Test unit conversion rules.""" + unit_system = METRIC_SYSTEM + assert unit_system.get_converted_unit(device_class, original_unit) == state_unit + + +@pytest.mark.parametrize( + "device_class, original_unit, state_unit", + ( + # Test distance conversion + (SensorDeviceClass.DISTANCE, UnitOfLength.CENTIMETERS, UnitOfLength.INCHES), + (SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, UnitOfLength.MILES), + (SensorDeviceClass.DISTANCE, UnitOfLength.METERS, UnitOfLength.FEET), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILLIMETERS, UnitOfLength.INCHES), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILES, None), + (SensorDeviceClass.DISTANCE, "very_long", None), + # Test gas meter conversion + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.GAS, "very_much", None), + # Test speed conversion + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.SPEED, UnitOfSpeed.FEET_PER_SECOND, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.SPEED, "very_fast", None), + # Test volume conversion + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, UnitOfVolume.FLUID_OUNCES), + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, None), + (SensorDeviceClass.VOLUME, "very_much", None), + # Test water meter conversion + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.WATER, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), + (SensorDeviceClass.WATER, "very_much", None), + ), +) +def test_get_us_converted_unit( + device_class: SensorDeviceClass, + original_unit: str, + state_unit: str | None, +) -> None: + """Test unit conversion rules.""" + unit_system = US_CUSTOMARY_SYSTEM + assert unit_system.get_converted_unit(device_class, original_unit) == state_unit diff --git a/tox.ini b/tox.ini index b96ab648fa288b..cbc98968177e05 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ isolated_build = True [testenv] basepython = {env:PYTHON3_PATH:python3} # pip version duplicated in homeassistant/package_constraints.txt -pip_version = pip>=21.0,<22.3 +pip_version = pip>=21.0,<22.4 install_command = python -m pip install --use-deprecated legacy-resolver {opts} {packages} commands = {envpython} -X dev -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar {posargs}