diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index d0e1a741f2e..986c5306e27 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import timedelta from math import ceil -from typing import Any, cast +from typing import Any from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -12,6 +12,7 @@ from pyairvisual.errors import ( InvalidKeyError, KeyExpiredError, NodeProError, + UnauthorizedError, ) from homeassistant.config_entries import ConfigEntry @@ -210,9 +211,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - data = await api_coro - return cast(dict[str, Any], data) - except (InvalidKeyError, KeyExpiredError) as ex: + return await api_coro + except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex: raise ConfigEntryAuthFailed from ex except AirVisualError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err @@ -253,8 +253,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with NodeSamba( entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD] ) as node: - data = await node.async_get_latest_measurements() - return cast(dict[str, Any], data) + return await node.async_get_latest_measurements() except NodeProError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index f97616c38fc..385c9f55753 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -9,8 +9,10 @@ from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( AirVisualError, InvalidKeyError, + KeyExpiredError, NodeProError, NotFoundError, + UnauthorizedError, ) import voluptuous as vol @@ -119,7 +121,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input[CONF_API_KEY] not in valid_keys: try: await coro - except InvalidKeyError: + except (InvalidKeyError, KeyExpiredError, UnauthorizedError): errors[CONF_API_KEY] = "invalid_api_key" except NotFoundError: errors[CONF_CITY] = "location_not_found" diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index ed803a3e6a1..9a6279f34a6 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -3,7 +3,7 @@ "name": "AirVisual", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airvisual", - "requirements": ["pyairvisual==5.0.9"], + "requirements": ["pyairvisual==2022.07.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "loggers": ["pyairvisual", "pysmb"] diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 9c03cd322b6..5c28d649936 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -88,6 +88,7 @@ class AladdinDevice(CoverEntity): self._device_id = device["device_id"] self._number = device["door_number"] self._attr_name = device["name"] + self._serial = device["serial"] self._attr_unique_id = f"{self._device_id}-{self._number}" async def async_added_to_hass(self) -> None: @@ -97,8 +98,8 @@ class AladdinDevice(CoverEntity): """Schedule a state update.""" self.async_write_ha_state() - self._acc.register_callback(update_callback, self._number) - await self._acc.get_doors(self._number) + self._acc.register_callback(update_callback, self._serial) + await self._acc.get_doors(self._serial) async def async_will_remove_from_hass(self) -> None: """Close Aladdin Connect before removing.""" @@ -114,7 +115,7 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._number) + await self._acc.get_doors(self._serial) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 5baeba33971..4a04bf69aed 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["AIOAladdinConnect==0.1.25"], + "requirements": ["AIOAladdinConnect==0.1.27"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/homeassistant/components/aladdin_connect/model.py b/homeassistant/components/aladdin_connect/model.py index 4248f3504fe..63624b223a9 100644 --- a/homeassistant/components/aladdin_connect/model.py +++ b/homeassistant/components/aladdin_connect/model.py @@ -11,3 +11,4 @@ class DoorDevice(TypedDict): door_number: int name: str status: str + serial: str diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index c837ef6fdec..a103316f3ff 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -281,14 +281,14 @@ SENSOR_DESCRIPTIONS = ( name="Lightning Strikes Per Day", icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_HOUR, name="Lightning Strikes Per Hour", icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_MAXDAILYGUST, diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index ecdcc5f70f2..dc2d2fee8e9 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -87,7 +87,7 @@ class ArubaDeviceScanner(DeviceScanner): def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" - connect = f"ssh {self.username}@{self.host}" + connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" ssh = pexpect.spawn(connect) query = ssh.expect( [ diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index e5a968b47fd..08e90d3c4e0 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -33,7 +33,8 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - use_metric_units=hass.config.units.is_metric, + # Force metric system as BMW API apparently only returns metric values now + use_metric_units=True, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index b10d4842163..0381035a63e 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.6"], + "requirements": ["bimmer_connected==0.10.1"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 9f19673c398..f1046881ed3 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, @@ -183,10 +182,8 @@ class BMWSensor(BMWBaseEntity, SensorEntity): self._attr_name = f"{vehicle.name} {description.key}" self._attr_unique_id = f"{vehicle.vin}-{description.key}" - if unit_system.name == CONF_UNIT_SYSTEM_IMPERIAL: - self._attr_native_unit_of_measurement = description.unit_imperial - else: - self._attr_native_unit_of_measurement = description.unit_metric + # Force metric system as BMW API apparently only returns metric values now + self._attr_native_unit_of_measurement = description.unit_metric @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 86de17ef285..600ed363a8b 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -99,7 +99,7 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): CONF_API_KEY, description={ "suggested_value": self.config_entry.options.get( - CONF_API_KEY + CONF_API_KEY, "" ) }, ): str, diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index c06237f3709..69345c430c7 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -44,13 +44,15 @@ class HiveDeviceLight(HiveEntity, LightEntity): super().__init__(hive, hive_device) if self.device["hiveType"] == "warmwhitelight": self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_color_mode = ColorMode.BRIGHTNESS elif self.device["hiveType"] == "tuneablelight": self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + self._attr_color_mode = ColorMode.COLOR_TEMP elif self.device["hiveType"] == "colourtuneablelight": self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - self._attr_min_mireds = self.device.get("min_mireds") - self._attr_max_mireds = self.device.get("max_mireds") + self._attr_min_mireds = 153 + self._attr_max_mireds = 370 @refresh_system async def async_turn_on(self, **kwargs): @@ -94,6 +96,13 @@ class HiveDeviceLight(HiveEntity, LightEntity): if self._attr_available: self._attr_is_on = self.device["status"]["state"] self._attr_brightness = self.device["status"]["brightness"] + if self.device["hiveType"] == "tuneablelight": + self._attr_color_temp = self.device["status"].get("color_temp") if self.device["hiveType"] == "colourtuneablelight": - rgb = self.device["status"]["hs_color"] - self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) + if self.device["status"]["mode"] == "COLOUR": + rgb = self.device["status"]["hs_color"] + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) + self._attr_color_mode = ColorMode.HS + else: + self._attr_color_temp = self.device["status"].get("color_temp") + self._attr_color_mode = ColorMode.COLOR_TEMP diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index dcac7238c8e..6337045a51f 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.components.automation import ( ) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, KNOWN_DEVICES, TRIGGERS @@ -86,13 +86,13 @@ class TriggerSource: ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_data = automation_info["trigger_data"] + job = HassJob(action) + @callback def event_handler(char): if config[CONF_SUBTYPE] != HK_TO_HA_INPUT_EVENT_VALUES[char["value"]]: return - self._hass.async_create_task( - action({"trigger": {**trigger_data, **config}}) - ) + self._hass.async_run_hass_job(job, {"trigger": {**trigger_data, **config}}) trigger = self._triggers[config[CONF_TYPE], config[CONF_SUBTYPE]] iid = trigger["characteristic"] @@ -231,11 +231,11 @@ async def async_setup_triggers_for_entry(hass: HomeAssistant, config_entry): def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], Any]): """Process events generated by a HomeKit accessory into automation triggers.""" + trigger_sources: dict[str, TriggerSource] = conn.hass.data[TRIGGERS] for (aid, iid), ev in events.items(): if aid in conn.devices: device_id = conn.devices[aid] - if device_id in conn.hass.data[TRIGGERS]: - source = conn.hass.data[TRIGGERS][device_id] + if source := trigger_sources.get(device_id): source.fire(iid, ev) diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index d082f77d837..c81c3e0c7f7 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -99,7 +99,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client = Client( host=host, port=port, - loop=hass.loop, update_interval=scan_interval.total_seconds(), infer_arming_state=infer_arming_state, ) diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 4aa01428d27..0b20ad7e6a9 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -2,7 +2,7 @@ "domain": "ness_alarm", "name": "Ness Alarm", "documentation": "https://www.home-assistant.io/integrations/ness_alarm", - "requirements": ["nessclient==0.9.15"], + "requirements": ["nessclient==0.10.0"], "codeowners": ["@nickw444"], "iot_class": "local_push", "loggers": ["nessclient"] diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 8d4a9b4912a..e913d488c8e 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -59,7 +59,9 @@ class NetgearUpdateEntity(NetgearRouterEntity, UpdateEntity): """Latest version available for install.""" if self.coordinator.data is not None: new_version = self.coordinator.data.get("NewVersion") - if new_version is not None: + if new_version is not None and not new_version.startswith( + self.installed_version + ): return new_version return self.installed_version diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 5ec8c6420d5..0d01550df22 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -416,7 +416,7 @@ class OpenThermGatewayDevice: self.status = {} self.update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_update" self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_options_update" - self.gateway = pyotgw.pyotgw() + self.gateway = pyotgw.OpenThermGateway() self.gw_version = None async def cleanup(self, event=None): @@ -427,7 +427,7 @@ class OpenThermGatewayDevice: async def connect_and_subscribe(self): """Connect to serial device and subscribe report handler.""" - self.status = await self.gateway.connect(self.hass.loop, self.device_path) + self.status = await self.gateway.connect(self.device_path) version_string = self.status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) self.gw_version = version_string[18:] if version_string else None _LOGGER.debug( diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 1d66d6e2069..3f91496adab 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -59,8 +59,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def test_connection(): """Try to connect to the OpenTherm Gateway.""" - otgw = pyotgw.pyotgw() - status = await otgw.connect(self.hass.loop, device) + otgw = pyotgw.OpenThermGateway() + status = await otgw.connect(device) await otgw.disconnect() return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 7aa19224020..dfb60413721 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -2,7 +2,7 @@ "domain": "opentherm_gw", "name": "OpenTherm Gateway", "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", - "requirements": ["pyotgw==1.1b1"], + "requirements": ["pyotgw==2.0.0"], "codeowners": ["@mvn23"], "config_flow": true, "iot_class": "local_push", diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index a9aade00933..48e48b618e4 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==2.0.0"], + "requirements": ["aioshelly==2.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index a09c273076c..b6a139fba80 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.06.1"], + "requirements": ["simplisafe-python==2022.07.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index ef68ea7bcae..f0b4ed99654 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -187,14 +187,15 @@ def filter_libav_logging() -> None: return logging.getLogger(__name__).isEnabledFor(logging.DEBUG) for logging_namespace in ( - "libav.mp4", + "libav.NULL", "libav.h264", "libav.hevc", + "libav.hls", + "libav.mp4", + "libav.mpegts", "libav.rtsp", "libav.tcp", "libav.tls", - "libav.mpegts", - "libav.NULL", ): logging.getLogger(logging_namespace).addFilter(libav_filter) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index cb485ffd8a5..23fb36a3a41 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,7 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.14.0"], + "requirements": ["PySwitchbot==0.14.1"], "config_flow": true, "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling", diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json index 5447b90d1ce..0823b5ac185 100644 --- a/homeassistant/components/tomorrowio/manifest.json +++ b/homeassistant/components/tomorrowio/manifest.json @@ -3,7 +3,7 @@ "name": "Tomorrow.io", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tomorrowio", - "requirements": ["pytomorrowio==0.3.3"], + "requirements": ["pytomorrowio==0.3.4"], "codeowners": ["@raman325", "@lymanepp"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 9e0783a99b1..d4140759a7b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -72,6 +72,7 @@ class ProtectData: self._pending_camera_ids: set[str] = set() self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None + self._auth_failures = 0 self.last_update_success = False self.api = protect @@ -117,9 +118,13 @@ class ProtectData: try: updates = await self.api.update(force=force) except NotAuthorized: - await self.async_stop() - _LOGGER.exception("Reauthentication required") - self._entry.async_start_reauth(self._hass) + if self._auth_failures < 10: + _LOGGER.exception("Auth error while updating") + self._auth_failures += 1 + else: + await self.async_stop() + _LOGGER.exception("Reauthentication required") + self._entry.async_start_reauth(self._hass) self.last_update_success = False except ClientError: if self.last_update_success: @@ -129,6 +134,7 @@ class ProtectData: self._async_process_updates(self.api.bootstrap) else: self.last_update_success = True + self._auth_failures = 0 self._async_process_updates(updates) @callback diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 2f3331af6e2..4a6eea28e24 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -3,7 +3,7 @@ "name": "Venstar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", - "requirements": ["venstarcolortouch==0.17"], + "requirements": ["venstarcolortouch==0.18"], "codeowners": ["@garbled1"], "iot_class": "local_polling", "loggers": ["venstarcolortouch"] diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index e1c85b39d8e..cc8dc475c43 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -73,7 +73,6 @@ CAPABILITIES_COLOR_LOOP = 0x4 CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 -DEFAULT_TRANSITION = 1 DEFAULT_MIN_BRIGHTNESS = 2 UPDATE_COLORLOOP_ACTION = 0x1 @@ -119,7 +118,7 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False - _DEFAULT_COLOR_FROM_OFF_TRANSITION = 0 + _DEFAULT_MIN_TRANSITION_TIME = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" @@ -140,7 +139,7 @@ class BaseLight(LogMixin, light.LightEntity): self._level_channel = None self._color_channel = None self._identify_channel = None - self._default_transition = None + self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME self._attr_color_mode = ColorMode.UNKNOWN # Set by sub classes @property @@ -216,33 +215,49 @@ class BaseLight(LogMixin, light.LightEntity): transition = kwargs.get(light.ATTR_TRANSITION) duration = ( transition * 10 - if transition - else self._default_transition * 10 - if self._default_transition - else DEFAULT_TRANSITION - ) + if transition is not None + else self._zha_config_transition * 10 + ) or self._DEFAULT_MIN_TRANSITION_TIME # if 0 is passed in some devices still need the minimum default brightness = kwargs.get(light.ATTR_BRIGHTNESS) effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) + temperature = kwargs.get(light.ATTR_COLOR_TEMP) + hs_color = kwargs.get(light.ATTR_HS_COLOR) # If the light is currently off but a turn_on call with a color/temperature is sent, # the light needs to be turned on first at a low brightness level where the light is immediately transitioned # to the correct color. Afterwards, the transition is only from the low brightness to the new brightness. # Otherwise, the transition is from the color the light had before being turned on to the new color. - # This can look especially bad with transitions longer than a second. - color_provided_from_off = ( - not self._state + # This can look especially bad with transitions longer than a second. We do not want to do this for + # devices that need to be forced to use the on command because we would end up with 4 commands sent: + # move to level, on, color, move to level... We also will not set this if the bulb is already in the + # desired color mode with the desired color or color temperature. + new_color_provided_while_off = ( + not isinstance(self, LightGroup) + and not self._FORCE_ON + and not self._state + and ( + ( + temperature is not None + and ( + self._color_temp != temperature + or self._attr_color_mode != ColorMode.COLOR_TEMP + ) + ) + or ( + hs_color is not None + and ( + self.hs_color != hs_color + or self._attr_color_mode != ColorMode.HS + ) + ) + ) and brightness_supported(self._attr_supported_color_modes) - and (light.ATTR_COLOR_TEMP in kwargs or light.ATTR_HS_COLOR in kwargs) ) - final_duration = duration - if color_provided_from_off: - # Set the duration for the color changing commands to 0. - duration = 0 if ( brightness is None - and (self._off_with_transition or color_provided_from_off) + and (self._off_with_transition or new_color_provided_while_off) and self._off_brightness is not None ): brightness = self._off_brightness @@ -254,11 +269,11 @@ class BaseLight(LogMixin, light.LightEntity): t_log = {} - if color_provided_from_off: + if new_color_provided_while_off: # If the light is currently off, we first need to turn it on at a low brightness level with no transition. # After that, we set it to the desired color/temperature with no transition. result = await self._level_channel.move_to_level_with_on_off( - DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_COLOR_FROM_OFF_TRANSITION + DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_MIN_TRANSITION_TIME ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -269,7 +284,7 @@ class BaseLight(LogMixin, light.LightEntity): if ( (brightness is not None or transition) - and not color_provided_from_off + and not new_color_provided_while_off and brightness_supported(self._attr_supported_color_modes) ): result = await self._level_channel.move_to_level_with_on_off( @@ -285,7 +300,7 @@ class BaseLight(LogMixin, light.LightEntity): if ( brightness is None - and not color_provided_from_off + and not new_color_provided_while_off or (self._FORCE_ON and brightness) ): # since some lights don't always turn on with move_to_level_with_on_off, @@ -297,9 +312,13 @@ class BaseLight(LogMixin, light.LightEntity): return self._state = True - if light.ATTR_COLOR_TEMP in kwargs: - temperature = kwargs[light.ATTR_COLOR_TEMP] - result = await self._color_channel.move_to_color_temp(temperature, duration) + if temperature is not None: + result = await self._color_channel.move_to_color_temp( + temperature, + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, + ) t_log["move_to_color_temp"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) @@ -308,11 +327,14 @@ class BaseLight(LogMixin, light.LightEntity): self._color_temp = temperature self._hs_color = None - if light.ATTR_HS_COLOR in kwargs: - hs_color = kwargs[light.ATTR_HS_COLOR] + if hs_color is not None: xy_color = color_util.color_hs_to_xy(*hs_color) result = await self._color_channel.move_to_color( - int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration + int(xy_color[0] * 65535), + int(xy_color[1] * 65535), + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, ) t_log["move_to_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -322,9 +344,9 @@ class BaseLight(LogMixin, light.LightEntity): self._hs_color = hs_color self._color_temp = None - if color_provided_from_off: + if new_color_provided_while_off: # The light is has the correct color, so we can now transition it to the correct brightness level. - result = await self._level_channel.move_to_level(level, final_duration) + result = await self._level_channel.move_to_level(level, duration) t_log["move_to_level_if_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) @@ -371,12 +393,13 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" - duration = kwargs.get(light.ATTR_TRANSITION) + transition = kwargs.get(light.ATTR_TRANSITION) supports_level = brightness_supported(self._attr_supported_color_modes) - if duration and supports_level: + # is not none looks odd here but it will override built in bulb transition times if we pass 0 in here + if transition is not None and supports_level: result = await self._level_channel.move_to_level_with_on_off( - 0, duration * 10 + 0, transition * 10 ) else: result = await self._on_off_channel.off() @@ -387,7 +410,7 @@ class BaseLight(LogMixin, light.LightEntity): if supports_level: # store current brightness so that the next turn_on uses it. - self._off_with_transition = bool(duration) + self._off_with_transition = transition is not None self._off_brightness = self._brightness self.async_write_ha_state() @@ -460,7 +483,7 @@ class Light(BaseLight, ZhaEntity): if effect_list: self._effect_list = effect_list - self._default_transition = async_get_zha_config_value( + self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, @@ -472,6 +495,7 @@ class Light(BaseLight, ZhaEntity): """Set the state.""" self._state = bool(value) if value: + self._off_with_transition = False self._off_brightness = None self.async_write_ha_state() @@ -605,7 +629,7 @@ class HueLight(Light): @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, - manufacturers={"Jasco", "Quotra-Vision"}, + manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect move_to_level_with_on_off.""" @@ -621,7 +645,7 @@ class ForceOnLight(Light): class SengledLight(Light): """Representation of a Sengled light which does not react to move_to_color_temp with 0 as a transition.""" - _DEFAULT_COLOR_FROM_OFF_TRANSITION = 1 + _DEFAULT_MIN_TRANSITION_TIME = 1 @GROUP_MATCH() @@ -639,7 +663,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): self._color_channel = group.endpoint[Color.cluster_id] self._identify_channel = group.endpoint[Identify.cluster_id] self._debounced_member_refresh = None - self._default_transition = async_get_zha_config_value( + self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, diff --git a/homeassistant/const.py b/homeassistant/const.py index a2d71ea6d71..08c92cee96e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "5" +PATCH_VERSION: Final = "6" __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) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index d69a4106728..1413f6d9b15 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -11,6 +11,10 @@ import orjson from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import ( + JSONEncoder as DefaultHASSJSONEncoder, + json_encoder_default as default_hass_orjson_encoder, +) from .file import write_utf8_file, write_utf8_file_atomic @@ -45,10 +49,12 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: return {} if default is None else default -def _orjson_encoder(data: Any) -> str: - """JSON encoder that uses orjson.""" +def _orjson_default_encoder(data: Any) -> str: + """JSON encoder that uses orjson with hass defaults.""" return orjson.dumps( - data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS + data, + option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS, + default=default_hass_orjson_encoder, ).decode("utf-8") @@ -64,13 +70,19 @@ def save_json( Returns True on success. """ - dump: Callable[[Any], Any] = json.dumps + dump: Callable[[Any], Any] try: - if encoder: + # For backwards compatibility, if they pass in the + # default json encoder we use _orjson_default_encoder + # which is the orjson equivalent to the default encoder. + if encoder and encoder is not DefaultHASSJSONEncoder: + # If they pass a custom encoder that is not the + # DefaultHASSJSONEncoder, we use the slow path of json.dumps + dump = json.dumps json_data = json.dumps(data, indent=2, cls=encoder) else: - dump = _orjson_encoder - json_data = _orjson_encoder(data) + dump = _orjson_default_encoder + json_data = _orjson_default_encoder(data) except TypeError as error: msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data, dump=dump))}" _LOGGER.error(msg) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e3add3a7c44..09e19af6840 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Iterator import fnmatch -from io import StringIO +from io import StringIO, TextIOWrapper import logging import os from pathlib import Path @@ -169,7 +169,7 @@ def parse_yaml( except yaml.YAMLError: # Loading failed, so we now load with the slow line loader # since the C one will not give us line numbers - if isinstance(content, (StringIO, TextIO)): + if isinstance(content, (StringIO, TextIO, TextIOWrapper)): # Rewind the stream so we can try again content.seek(0, 0) return _parse_yaml_pure_python(content, secrets) diff --git a/pyproject.toml b/pyproject.toml index c68cd921da2..191b337eb65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.5" +version = "2022.7.6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index bb4bad5d959..11b785c4fc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.25 +AIOAladdinConnect==0.1.27 # homeassistant.components.adax Adax-local==0.1.4 @@ -37,7 +37,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.14.0 +PySwitchbot==0.14.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -244,7 +244,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.0 +aioshelly==2.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -396,7 +396,7 @@ beautifulsoup4==4.11.1 bellows==0.31.1 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.6 +bimmer_connected==0.10.1 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -1068,7 +1068,7 @@ nad_receiver==0.3.0 ndms2_client==0.1.1 # homeassistant.components.ness_alarm -nessclient==0.9.15 +nessclient==0.10.0 # homeassistant.components.netdata netdata==1.0.1 @@ -1366,7 +1366,7 @@ pyaftership==21.11.0 pyairnow==1.1.0 # homeassistant.components.airvisual -pyairvisual==5.0.9 +pyairvisual==2022.07.0 # homeassistant.components.almond pyalmond==0.0.2 @@ -1715,7 +1715,7 @@ pyopnsense==0.2.0 pyoppleio==1.0.5 # homeassistant.components.opentherm_gw -pyotgw==1.1b1 +pyotgw==2.0.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1973,7 +1973,7 @@ pythonegardia==1.0.40 pytile==2022.02.0 # homeassistant.components.tomorrowio -pytomorrowio==0.3.3 +pytomorrowio==0.3.4 # homeassistant.components.touchline pytouchline==0.7 @@ -2168,7 +2168,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.06.1 +simplisafe-python==2022.07.0 # homeassistant.components.sisyphus sisyphus-control==3.1.2 @@ -2387,7 +2387,7 @@ vehicle==0.4.0 velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.17 +venstarcolortouch==0.18 # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79318dc1789..63ef39eb63e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.25 +AIOAladdinConnect==0.1.27 # homeassistant.components.adax Adax-local==0.1.4 @@ -33,7 +33,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.14.0 +PySwitchbot==0.14.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -213,7 +213,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.0 +aioshelly==2.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -311,7 +311,7 @@ beautifulsoup4==4.11.1 bellows==0.31.1 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.6 +bimmer_connected==0.10.1 # homeassistant.components.blebox blebox_uniapi==2.0.1 @@ -742,7 +742,7 @@ mutesync==0.0.1 ndms2_client==0.1.1 # homeassistant.components.ness_alarm -nessclient==0.9.15 +nessclient==0.10.0 # homeassistant.components.discovery netdisco==3.0.0 @@ -929,7 +929,7 @@ pyaehw4a1==0.3.9 pyairnow==1.1.0 # homeassistant.components.airvisual -pyairvisual==5.0.9 +pyairvisual==2022.07.0 # homeassistant.components.almond pyalmond==0.0.2 @@ -1167,7 +1167,7 @@ pyopenuv==2022.04.0 pyopnsense==0.2.0 # homeassistant.components.opentherm_gw -pyotgw==1.1b1 +pyotgw==2.0.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1314,7 +1314,7 @@ python_awair==0.2.3 pytile==2022.02.0 # homeassistant.components.tomorrowio -pytomorrowio==0.3.3 +pytomorrowio==0.3.4 # homeassistant.components.traccar pytraccar==0.10.0 @@ -1440,7 +1440,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.06.1 +simplisafe-python==2022.07.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1590,7 +1590,7 @@ vehicle==0.4.0 velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.17 +venstarcolortouch==0.18 # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index f0a75417487..9f9deca384c 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -4,8 +4,10 @@ from unittest.mock import patch from pyairvisual.errors import ( AirVisualError, InvalidKeyError, + KeyExpiredError, NodeProError, NotFoundError, + UnauthorizedError, ) import pytest @@ -84,6 +86,28 @@ async def test_duplicate_error(hass, config, config_entry, data): {CONF_API_KEY: "invalid_api_key"}, INTEGRATION_TYPE_GEOGRAPHY_NAME, ), + ( + { + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + }, + KeyExpiredError, + {CONF_API_KEY: "invalid_api_key"}, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ), + ( + { + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + }, + UnauthorizedError, + {CONF_API_KEY: "invalid_api_key"}, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ), ( { CONF_API_KEY: "abcde12345", diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index ee68d207361..c8f7d240ba5 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -10,6 +10,7 @@ DEVICE_CONFIG_OPEN = { "name": "home", "status": "open", "link_status": "Connected", + "serial": "12345", } diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index 54ec4ee5de1..4e65607fa9d 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -33,6 +33,7 @@ DEVICE_CONFIG_OPEN = { "name": "home", "status": "open", "link_status": "Connected", + "serial": "12345", } DEVICE_CONFIG_OPENING = { @@ -41,6 +42,7 @@ DEVICE_CONFIG_OPENING = { "name": "home", "status": "opening", "link_status": "Connected", + "serial": "12345", } DEVICE_CONFIG_CLOSED = { @@ -49,6 +51,7 @@ DEVICE_CONFIG_CLOSED = { "name": "home", "status": "closed", "link_status": "Connected", + "serial": "12345", } DEVICE_CONFIG_CLOSING = { @@ -57,6 +60,7 @@ DEVICE_CONFIG_CLOSING = { "name": "home", "status": "closing", "link_status": "Connected", + "serial": "12345", } DEVICE_CONFIG_DISCONNECTED = { @@ -65,6 +69,7 @@ DEVICE_CONFIG_DISCONNECTED = { "name": "home", "status": "open", "link_status": "Disconnected", + "serial": "12345", } DEVICE_CONFIG_BAD = { diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 99133cf17c3..344b21e4471 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -43,10 +43,12 @@ async def test_form_user(hass): "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "pyotgw.pyotgw.connect", return_value=MINIMAL_STATUS + "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS ) as mock_pyotgw_connect, patch( - "pyotgw.pyotgw.disconnect", return_value=None - ) as mock_pyotgw_disconnect: + "pyotgw.OpenThermGateway.disconnect", return_value=None + ) as mock_pyotgw_disconnect, patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} ) @@ -75,10 +77,12 @@ async def test_form_import(hass): "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "pyotgw.pyotgw.connect", return_value=MINIMAL_STATUS + "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS ) as mock_pyotgw_connect, patch( - "pyotgw.pyotgw.disconnect", return_value=None - ) as mock_pyotgw_disconnect: + "pyotgw.OpenThermGateway.disconnect", return_value=None + ) as mock_pyotgw_disconnect, patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -117,10 +121,12 @@ async def test_form_duplicate_entries(hass): "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "pyotgw.pyotgw.connect", return_value=MINIMAL_STATUS + "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS ) as mock_pyotgw_connect, patch( - "pyotgw.pyotgw.disconnect", return_value=None - ) as mock_pyotgw_disconnect: + "pyotgw.OpenThermGateway.disconnect", return_value=None + ) as mock_pyotgw_disconnect, patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): result1 = await hass.config_entries.flow.async_configure( flow1["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} ) @@ -148,8 +154,10 @@ async def test_form_connection_timeout(hass): ) with patch( - "pyotgw.pyotgw.connect", side_effect=(asyncio.TimeoutError) - ) as mock_connect: + "pyotgw.OpenThermGateway.connect", side_effect=(asyncio.TimeoutError) + ) as mock_connect, patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "socket://192.0.2.254:1234"}, @@ -166,7 +174,11 @@ async def test_form_connection_error(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("pyotgw.pyotgw.connect", side_effect=(SerialException)) as mock_connect: + with patch( + "pyotgw.OpenThermGateway.connect", side_effect=(SerialException) + ) as mock_connect, patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} ) @@ -196,7 +208,11 @@ async def test_options_migration(hass): with patch( "homeassistant.components.opentherm_gw.OpenThermGatewayDevice.connect_and_subscribe", return_value=True, - ), patch("homeassistant.components.opentherm_gw.async_setup", return_value=True): + ), patch( + "homeassistant.components.opentherm_gw.async_setup", return_value=True + ), patch( + "pyotgw.status.StatusManager._process_updates", return_value=None + ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 554f58fd81b..7e16805c683 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -34,7 +34,7 @@ async def test_device_registry_insert(hass): with patch( "homeassistant.components.opentherm_gw.OpenThermGatewayDevice.cleanup", return_value=None, - ), patch("pyotgw.pyotgw.connect", return_value=MINIMAL_STATUS): + ), patch("pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -62,7 +62,7 @@ async def test_device_registry_update(hass): with patch( "homeassistant.components.opentherm_gw.OpenThermGatewayDevice.cleanup", return_value=None, - ), patch("pyotgw.pyotgw.connect", return_value=MINIMAL_STATUS_UPD): + ), patch("pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS_UPD): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index f6f0645df18..9392caa30ac 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -9,14 +9,18 @@ import aiohttp from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR, Bootstrap, Light -from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN +from homeassistant.components.unifiprotect.const import ( + CONF_DISABLE_RTSP, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState 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_discovery -from .utils import MockUFPFixture, init_entry +from .utils import MockUFPFixture, init_entry, time_changed from tests.common import MockConfigEntry @@ -145,12 +149,23 @@ async def test_setup_failed_update(hass: HomeAssistant, ufp: MockUFPFixture): async def test_setup_failed_update_reauth(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with update that gives unauthroized error.""" - ufp.api.update = AsyncMock(side_effect=NotAuthorized) - await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert ufp.entry.state == ConfigEntryState.SETUP_RETRY - assert ufp.api.update.called + assert ufp.entry.state == ConfigEntryState.LOADED + + # reauth should not be triggered until there are 10 auth failures in a row + # to verify it is not transient + ufp.api.update = AsyncMock(side_effect=NotAuthorized) + for _ in range(10): + await time_changed(hass, DEFAULT_SCAN_INTERVAL) + assert len(hass.config_entries.flow._progress) == 0 + + assert ufp.api.update.call_count == 10 + assert ufp.entry.state == ConfigEntryState.LOADED + + await time_changed(hass, DEFAULT_SCAN_INTERVAL) + assert ufp.api.update.call_count == 11 + assert len(hass.config_entries.flow._progress) == 1 async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture): diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index dd6df0dff19..982ff622341 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -15,7 +15,11 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.components.zha.core.group import GroupMember -from homeassistant.components.zha.light import FLASH_EFFECTS +from homeassistant.components.zha.light import ( + CAPABILITIES_COLOR_TEMP, + CAPABILITIES_COLOR_XY, + FLASH_EFFECTS, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util @@ -142,6 +146,10 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): ieee=IEEE_GROUPABLE_DEVICE, nwk=0xB79D, ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True return zha_device @@ -167,8 +175,13 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): } }, ieee=IEEE_GROUPABLE_DEVICE2, + manufacturer="Sengled", nwk=0xC79E, ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True return zha_device @@ -201,6 +214,38 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): return zha_device +@pytest.fixture +async def eWeLink_light(hass, zigpy_device_mock, zha_device_joined): + """Mock eWeLink 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, + } + }, + ieee="03:2d:6f:00:0a:90:69:e3", + manufacturer="eWeLink", + nwk=0xB79D, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): """Test zha light platform refresh.""" @@ -323,6 +368,758 @@ async def test_light( await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_LONG) +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_transitions( + hass, device_light_1, device_light_2, eWeLink_light, coordinator +): + """Test ZHA light transition code.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_light_1._zha_gateway = zha_gateway + device_light_2._zha_gateway = zha_gateway + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [GroupMember(device_light_1.ieee, 1), GroupMember(device_light_2.ieee, 1)] + + assert coordinator.is_coordinator + + # test creating a group with 2 members + zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await hass.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) + eWeLink_light_entity_id = await find_entity_id(Platform.LIGHT, eWeLink_light, hass) + assert device_1_entity_id != device_2_entity_id + + group_entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group) + assert hass.states.get(group_entity_id) is not None + + assert device_1_entity_id in zha_group.member_entity_ids + assert device_2_entity_id in zha_group.member_entity_ids + + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_light_2.device.endpoints[1].on_off + eWeLink_cluster_on_off = eWeLink_light.device.endpoints[1].on_off + + dev1_cluster_level = device_light_1.device.endpoints[1].level + dev2_cluster_level = device_light_2.device.endpoints[1].level + eWeLink_cluster_level = eWeLink_light.device.endpoints[1].level + + dev1_cluster_color = device_light_1.device.endpoints[1].light_color + dev2_cluster_color = device_light_2.device.endpoints[1].light_color + eWeLink_cluster_color = eWeLink_light.device.endpoints[1].light_color + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [device_light_1, device_light_2]) + await async_wait_for_updates(hass) + + # test that the lights were created and are off + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_OFF + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + # first test 0 length transition with no color provided + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_1_entity_id, "transition": 0, "brightness": 50}, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 50, # brightness (level in ZCL) + 0, # transition time + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_ON + assert light1_state.attributes["brightness"] == 50 + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition with color provided while light is on + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "transition": 3, + "brightness": 18, + "color_temp": 432, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 18, # brightness (level in ZCL) + 30, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 432, # color temp mireds + 30.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_ON + assert light1_state.attributes["brightness"] == 18 + assert light1_state.attributes["color_temp"] == 432 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # test 0 length transition to turn light off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + "transition": 0, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 0, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_OFF + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition and color temp while turning light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 2 + assert dev1_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev1_cluster_level.request.call_args_list[0] == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_ON + assert light1_state.attributes["brightness"] == 25 + assert light1_state.attributes["color_temp"] == 235 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition provided and color temp while turning light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "brightness": 25, + "color_temp": 236, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 2 + assert dev1_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev1_cluster_level.request.call_args_list[0] == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 236, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_ON + assert light1_state.attributes["brightness"] == 25 + assert light1_state.attributes["color_temp"] == 236 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition when the same color temp is provided from off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "color_temp": 236, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + + assert dev1_cluster_on_off.request.call_args == call( + False, + 1, + dev1_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 236, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_ON + assert light1_state.attributes["brightness"] == 25 + assert light1_state.attributes["color_temp"] == 236 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + blocking=True, + ) + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test sengled light uses default minimum transition time + dev2_cluster_on_off.request.reset_mock() + dev2_cluster_color.request.reset_mock() + dev2_cluster_level.request.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_2_entity_id, "transition": 0, "brightness": 100}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 100, # brightness (level in ZCL) + 1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + assert light2_state.attributes["brightness"] == 100 + + dev2_cluster_level.request.reset_mock() + + # turn the sengled light back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning light on and sengled (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_2_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 1 + assert dev2_cluster_color.request.await_count == 1 + assert dev2_cluster_level.request.call_count == 2 + assert dev2_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev2_cluster_level.request.call_args_list[0] == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 1, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev2_cluster_color.request.call_args == call( + False, + 10, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 1, # transition time (ZCL time in 10ths of a second) - sengled transition == 1 when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev2_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev2_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + assert light2_state.attributes["brightness"] == 25 + assert light2_state.attributes["color_temp"] == 235 + assert light2_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev2_cluster_level.request.reset_mock() + dev2_cluster_color.request.reset_mock() + + # turn the sengled light back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning group light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": group_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + blocking=True, + ) + + group_on_off_channel = zha_group.endpoint[general.OnOff.cluster_id] + group_level_channel = zha_group.endpoint[general.LevelControl.cluster_id] + group_color_channel = zha_group.endpoint[lighting.Color.cluster_id] + assert group_on_off_channel.request.call_count == 0 + assert group_on_off_channel.request.await_count == 0 + assert group_color_channel.request.call_count == 1 + assert group_color_channel.request.await_count == 1 + assert group_level_channel.request.call_count == 1 + assert group_level_channel.request.await_count == 1 + + # groups are omitted from the 3 call dance for color_provided_while_off + assert group_color_channel.request.call_args == call( + False, + 10, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 10.0, # transition time (ZCL time in 10ths of a second) - sengled transition == 1 when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert group_level_channel.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_ON + assert group_state.attributes["brightness"] == 25 + assert group_state.attributes["color_temp"] == 235 + assert group_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + group_on_off_channel.request.reset_mock() + group_color_channel.request.reset_mock() + group_level_channel.request.reset_mock() + + # turn the sengled light back on + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + + dev2_cluster_on_off.request.reset_mock() + + # turn the light off with a transition + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + {"entity_id": device_2_entity_id, "transition": 2}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 0, # brightness (level in ZCL) + 20, # transition time + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_level.request.reset_mock() + + # turn the light back on with no args should use a transition and last known brightness + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_2_entity_id}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 25, # brightness (level in ZCL) - this is the last brightness we set a few tests above + 1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + + dev2_cluster_level.request.reset_mock() + + # test eWeLink color temp while turning light on from off (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": eWeLink_light_entity_id, + "color_temp": 235, + }, + blocking=True, + ) + assert eWeLink_cluster_on_off.request.call_count == 1 + assert eWeLink_cluster_on_off.request.await_count == 1 + assert eWeLink_cluster_color.request.call_count == 1 + assert eWeLink_cluster_color.request.await_count == 1 + assert eWeLink_cluster_level.request.call_count == 0 + assert eWeLink_cluster_level.request.await_count == 0 + + # first it comes on + assert eWeLink_cluster_on_off.request.call_args_list[0] == call( + False, + 1, + eWeLink_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + eWeLink_state = hass.states.get(eWeLink_light_entity_id) + assert eWeLink_state.state == STATE_ON + assert eWeLink_state.attributes["color_temp"] == 235 + assert eWeLink_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + async def async_test_on_off_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" # turn on at light @@ -463,7 +1260,7 @@ async def async_test_level_on_off_from_hass( 4, level_cluster.commands_by_name["move_to_level_with_on_off"].schema, 10, - 1, + 0, expect_reply=True, manufacturer=None, tries=1, @@ -601,7 +1398,10 @@ async def test_zha_group_light_entity( # test that the lights were created and are off group_state = hass.states.get(group_entity_id) assert group_state.state == STATE_OFF - assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] # Light which is off has no color mode assert "color_mode" not in group_state.attributes @@ -629,7 +1429,10 @@ async def test_zha_group_light_entity( # Check state group_state = hass.states.get(group_entity_id) assert group_state.state == STATE_ON - assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert group_state.attributes["color_mode"] == ColorMode.HS # test long flashing the lights from the HA diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 53c1b8a4677..ca5cb92bfd5 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import json +from typing import NamedTuple from unittest.mock import Mock, patch import pytest @@ -13,8 +14,9 @@ from homeassistant.const import ( from homeassistant.core import CoreState from homeassistant.helpers import storage from homeassistant.util import dt +from homeassistant.util.color import RGBColor -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, async_test_home_assistant MOCK_VERSION = 1 MOCK_VERSION_2 = 2 @@ -460,3 +462,47 @@ async def test_changing_delayed_written_data(hass, store, hass_storage): "key": MOCK_KEY, "data": {"hello": "world"}, } + + +async def test_saving_load_round_trip(tmpdir): + """Test saving and loading round trip.""" + loop = asyncio.get_running_loop() + hass = await async_test_home_assistant(loop) + + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "temp_storage" + ) + + class NamedTupleSubclass(NamedTuple): + """A NamedTuple subclass.""" + + name: str + + nts = NamedTupleSubclass("a") + + data = { + "named_tuple_subclass": nts, + "rgb_color": RGBColor(255, 255, 0), + "set": {1, 2, 3}, + "list": [1, 2, 3], + "tuple": (1, 2, 3), + "dict_with_int": {1: 1, 2: 2}, + "dict_with_named_tuple": {1: nts, 2: nts}, + } + + store = storage.Store( + hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 + ) + await store.async_save(data) + load = await store.async_load() + assert load == { + "dict_with_int": {"1": 1, "2": 2}, + "dict_with_named_tuple": {"1": ["a"], "2": ["a"]}, + "list": [1, 2, 3], + "named_tuple_subclass": ["a"], + "rgb_color": [255, 255, 0], + "set": [1, 2, 3], + "tuple": [1, 2, 3], + } + + await hass.async_stop(force=True) diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 9974cbb9628..509c0376fae 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -5,13 +5,13 @@ from json import JSONEncoder, dumps import math import os from tempfile import mkdtemp -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.template import TupleWrapper +from homeassistant.helpers.json import JSONEncoder as DefaultHASSJSONEncoder from homeassistant.util.json import ( SerializationError, find_paths_unserializable_data, @@ -82,23 +82,15 @@ def test_overwrite_and_reload(atomic_writes): def test_save_bad_data(): """Test error from trying to save unserializable data.""" + + class CannotSerializeMe: + """Cannot serialize this.""" + with pytest.raises(SerializationError) as excinfo: - save_json("test4", {"hello": set()}) + save_json("test4", {"hello": CannotSerializeMe()}) - assert ( - "Failed to serialize to JSON: test4. Bad data at $.hello=set()(" - in str(excinfo.value) - ) - - -def test_save_bad_data_tuple_wrapper(): - """Test error from trying to save unserializable data.""" - with pytest.raises(SerializationError) as excinfo: - save_json("test4", {"hello": TupleWrapper(("4", "5"))}) - - assert ( - "Failed to serialize to JSON: test4. Bad data at $.hello=('4', '5')(" - in str(excinfo.value) + assert "Failed to serialize to JSON: test4. Bad data at $.hello=" in str( + excinfo.value ) @@ -127,6 +119,21 @@ def test_custom_encoder(): assert data == "9" +def test_default_encoder_is_passed(): + """Test we use orjson if they pass in the default encoder.""" + fname = _path_for("test6") + with patch( + "homeassistant.util.json.orjson.dumps", return_value=b"{}" + ) as mock_orjson_dumps: + save_json(fname, {"any": 1}, encoder=DefaultHASSJSONEncoder) + assert len(mock_orjson_dumps.mock_calls) == 1 + # Patch json.dumps to make sure we are using the orjson path + with patch("homeassistant.util.json.json.dumps", side_effect=Exception): + save_json(fname, {"any": {1}}, encoder=DefaultHASSJSONEncoder) + data = load_json(fname) + assert data == {"any": [1]} + + def test_find_unserializable_data(): """Find unserializeable data.""" assert find_paths_unserializable_data(1) == {} diff --git a/tests/util/yaml/fixtures/bad.yaml.txt b/tests/util/yaml/fixtures/bad.yaml.txt new file mode 100644 index 00000000000..8f6a62a6511 --- /dev/null +++ b/tests/util/yaml/fixtures/bad.yaml.txt @@ -0,0 +1,26 @@ +- id: '1658085239190' + alias: Config validation test + description: '' + trigger: + - platform: time + at: 00:02:03 + condition: [] + action: + - service: script.notify_admin + data: + title: 'Here's something that does not work...!' + message: failing + mode: single +- id: '165808523911590' + alias: Config validation test FIXED + description: '' + trigger: + - platform: time + at: 00:02:03 + condition: [] + action: + - service: script.notify_admin + data: + title: 'Here is something that should work...!' + message: fixed? + mode: single diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 11dc40233dc..8d1b7c1adf1 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -2,6 +2,7 @@ import importlib import io import os +import pathlib import unittest from unittest.mock import patch @@ -490,3 +491,12 @@ def test_input(try_both_loaders, try_both_dumpers): def test_c_loader_is_available_in_ci(): """Verify we are testing the C loader in the CI.""" assert yaml.loader.HAS_C_LOADER is True + + +async def test_loading_actual_file_with_syntax(hass, try_both_loaders): + """Test loading a real file with syntax errors.""" + with pytest.raises(HomeAssistantError): + fixture_path = pathlib.Path(__file__).parent.joinpath( + "fixtures", "bad.yaml.txt" + ) + await hass.async_add_executor_job(load_yaml_config_file, fixture_path)