diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index dd98a2bc051..dfeb9fce25b 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -237,12 +237,14 @@ def _is_url(url): return all([result.scheme, result.netloc]) -async def _fetch_playlist(hass, url): +async def _fetch_playlist(hass, url, supported_content_types): """Fetch a playlist from the given url.""" try: session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) async with session.get(url, timeout=5) as resp: charset = resp.charset or "utf-8" + if resp.content_type in supported_content_types: + raise PlaylistSupported try: playlist_data = (await resp.content.read(64 * 1024)).decode(charset) except ValueError as err: @@ -260,7 +262,16 @@ async def parse_m3u(hass, url): Based on https://github.com/dvndrsn/M3uParser/blob/master/m3uparser.py """ - m3u_data = await _fetch_playlist(hass, url) + # From Mozilla gecko source: https://github.com/mozilla/gecko-dev/blob/c4c1adbae87bf2d128c39832d72498550ee1b4b8/dom/media/DecoderTraits.cpp#L47-L52 + hls_content_types = ( + # https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-10 + "application/vnd.apple.mpegurl", + # Some sites serve these as the informal HLS m3u type. + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + ) + m3u_data = await _fetch_playlist(hass, url, hls_content_types) m3u_lines = m3u_data.splitlines() playlist = [] @@ -301,7 +312,7 @@ async def parse_pls(hass, url): Based on https://github.com/mariob/plsparser/blob/master/src/plsparser.py """ - pls_data = await _fetch_playlist(hass, url) + pls_data = await _fetch_playlist(hass, url, ()) pls_parser = configparser.ConfigParser() try: diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index cee46913937..10edc81e0fc 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==12.1.1"], + "requirements": ["pychromecast==12.1.2"], "after_dependencies": [ "cloud", "http", diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 1ce3477db70..2a4a5ccf253 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==91"], + "requirements": ["pydeconz==92"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 9d0309bc4ee..08a9e651668 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -112,6 +112,7 @@ class FibaroLight(FibaroDevice, LightEntity): if ATTR_BRIGHTNESS in kwargs: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] self.set_level(scaleto99(self._attr_brightness)) + return if ATTR_RGB_COLOR in kwargs: # Update based on parameters diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index d20032e2607..197890efad3 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -37,6 +37,7 @@ from .const import ( CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DEFAULT_NAME, FFMPEG_OPTION_MAP, GET_IMAGE_TIMEOUT, @@ -160,6 +161,10 @@ class GenericCamera(Camera): CONF_RTSP_TRANSPORT ] self._auth = generate_auth(device_info) + if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + self.stream_options[ + FFMPEG_OPTION_MAP[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] + ] = "1" self._last_url = None self._last_image = None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 086262aa0a1..0a49393d9cc 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DEFAULT_NAME, DOMAIN, FFMPEG_OPTION_MAP, @@ -64,6 +65,7 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} def build_schema( user_input: dict[str, Any] | MappingProxyType[str, Any], is_options_flow: bool = False, + show_advanced_options=False, ): """Create schema for camera config setup.""" spec = { @@ -106,6 +108,13 @@ def build_schema( default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False), ) ] = bool + if show_advanced_options: + spec[ + vol.Required( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), + ) + ] = bool return vol.Schema(spec) @@ -199,6 +208,8 @@ async def async_test_stream(hass, info) -> dict[str, str]: } if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = rtsp_transport + if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + stream_options[FFMPEG_OPTION_MAP[CONF_USE_WALLCLOCK_AS_TIMESTAMPS]] = "1" _LOGGER.debug("Attempting to open stream %s", stream_source) container = await hass.async_add_executor_job( partial( @@ -356,6 +367,9 @@ class GenericOptionsFlowHandler(OptionsFlow): ], CONF_FRAMERATE: user_input[CONF_FRAMERATE], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_USE_WALLCLOCK_AS_TIMESTAMPS: user_input.get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS + ), } return self.async_create_entry( title=title, @@ -363,6 +377,10 @@ class GenericOptionsFlowHandler(OptionsFlow): ) return self.async_show_form( step_id="init", - data_schema=build_schema(user_input or self.config_entry.options, True), + data_schema=build_schema( + user_input or self.config_entry.options, + True, + self.show_advanced_options, + ), errors=errors, ) diff --git a/homeassistant/components/generic/const.py b/homeassistant/components/generic/const.py index 60b4cec61a6..8ae5f16c4c4 100644 --- a/homeassistant/components/generic/const.py +++ b/homeassistant/components/generic/const.py @@ -8,7 +8,11 @@ CONF_STILL_IMAGE_URL = "still_image_url" CONF_STREAM_SOURCE = "stream_source" CONF_FRAMERATE = "framerate" CONF_RTSP_TRANSPORT = "rtsp_transport" -FFMPEG_OPTION_MAP = {CONF_RTSP_TRANSPORT: "rtsp_transport"} +CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" +FFMPEG_OPTION_MAP = { + CONF_RTSP_TRANSPORT: "rtsp_transport", + CONF_USE_WALLCLOCK_AS_TIMESTAMPS: "use_wallclock_as_timestamps", +} RTSP_TRANSPORTS = { "tcp": "TCP", "udp": "UDP", diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 01b1fe48a82..0954656f71d 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -55,9 +55,13 @@ "authentication": "[%key:component::generic::config::step::user::data::authentication%]", "limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]", "password": "[%key:common::config_flow::data::password%]", + "use_wallclock_as_timestamps": "Use wallclock as timestamps", "username": "[%key:common::config_flow::data::username%]", "framerate": "[%key:component::generic::config::step::user::data::framerate%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" } }, "content_type": { diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index b158488f178..b552c780d29 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -32,7 +32,6 @@ "user": { "data": { "authentication": "Authentication", - "content_type": "Content Type", "framerate": "Frame Rate (Hz)", "limit_refetch_to_url_change": "Limit refetch to url change", "password": "Password", @@ -72,15 +71,18 @@ "init": { "data": { "authentication": "Authentication", - "content_type": "Content Type", "framerate": "Frame Rate (Hz)", "limit_refetch_to_url_change": "Limit refetch to url change", "password": "Password", "rtsp_transport": "RTSP transport protocol", "still_image_url": "Still Image URL (e.g. http://...)", "stream_source": "Stream Source URL (e.g. rtsp://...)", + "use_wallclock_as_timestamps": "Use wallclock as timestamps", "username": "Username", "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" } } } diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 3f22f4cc32b..3d21cca6b6d 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -96,7 +96,11 @@ class HistoryStats: new_data = False if event and event.data["new_state"] is not None: new_state: State = event.data["new_state"] - if current_period_start <= new_state.last_changed <= current_period_end: + if ( + current_period_start_timestamp + <= floored_timestamp(new_state.last_changed) + <= current_period_end_timestamp + ): self._history_current_period.append(new_state) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index 60935f3f951..67d30ba8cad 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -153,10 +153,9 @@ class InsteonEntity(Entity): def get_device_property(self, name: str): """Get a single Insteon device property value (raw).""" - value = None if (prop := self._insteon_device.properties.get(name)) is not None: - value = prop.value if prop.new_value is None else prop.new_value - return value + return prop.value + return None def _get_label(self): """Get the device label for grouped devices.""" diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 05ad9794042..bf8b693b103 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -58,9 +58,9 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity): """Turn light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = int(kwargs[ATTR_BRIGHTNESS]) - else: + elif self._insteon_device_group.group == 1: brightness = self.get_device_property(ON_LEVEL) - if brightness is not None: + if brightness: await self._insteon_device.async_on( on_level=brightness, group=self._insteon_device_group.group ) diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json index 94c040f3b75..2d8495df576 100644 --- a/homeassistant/components/logi_circle/manifest.json +++ b/homeassistant/components/logi_circle/manifest.json @@ -3,7 +3,7 @@ "name": "Logi Circle", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/logi_circle", - "requirements": ["logi_circle==0.2.2"], + "requirements": ["logi_circle==0.2.3"], "dependencies": ["ffmpeg", "http"], "codeowners": ["@evanjd"], "iot_class": "cloud_polling", diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 8c719d588d8..fecde148a5e 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -176,8 +176,6 @@ class MeaterProbeTemperature( ): """Meater Temperature Sensor Entity.""" - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS entity_description: MeaterSensorEntityDescription def __init__( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 16231ef0b88..a842af46f84 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==1.2.3"], + "requirements": ["nettigo-air-monitor==1.2.4"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f45362c6e6c..d376b7fe258 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -165,17 +165,13 @@ class ONVIFDevice: ) return + tzone = dt_util.DEFAULT_TIME_ZONE + cdate = device_time.LocalDateTime if device_time.UTCDateTime: tzone = dt_util.UTC cdate = device_time.UTCDateTime - else: - tzone = ( - dt_util.get_time_zone( - device_time.TimeZone or str(dt_util.DEFAULT_TIME_ZONE) - ) - or dt_util.DEFAULT_TIME_ZONE - ) - cdate = device_time.LocalDateTime + elif device_time.TimeZone: + tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone if cdate is None: LOGGER.warning("Could not retrieve date/time on this camera") diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index aca50e404a2..c03d27fefe5 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,4 +1,6 @@ """Support for monitoring an SABnzbd NZB client.""" +from __future__ import annotations + from collections.abc import Callable import logging @@ -14,12 +16,13 @@ from homeassistant.const import ( CONF_PORT, CONF_SENSORS, CONF_SSL, - CONF_URL, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import async_get from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -42,7 +45,7 @@ from .const import ( UPDATE_INTERVAL, ) from .sab import get_client -from .sensor import SENSOR_KEYS +from .sensor import OLD_SENSOR_KEYS PLATFORMS = ["sensor"] _LOGGER = logging.getLogger(__name__) @@ -76,12 +79,11 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): str, vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_URL): str, vol.Optional(CONF_PATH): str, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] + cv.ensure_list, [vol.In(OLD_SENSOR_KEYS)] ), vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }, @@ -123,8 +125,56 @@ def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) raise ValueError(f"No api for API key: {call_data_api_key}") +def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry): + """Update device identifiers to new identifiers.""" + device_registry = async_get(hass) + device_entry = device_registry.async_get_device({(DOMAIN, DOMAIN)}) + if device_entry and entry.entry_id in device_entry.config_entries: + new_identifiers = {(DOMAIN, entry.entry_id)} + _LOGGER.debug( + "Updating device id <%s> with new identifiers <%s>", + device_entry.id, + new_identifiers, + ) + device_registry.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + +async def migrate_unique_id(hass: HomeAssistant, entry: ConfigEntry): + """Migrate entities to new unique ids (with entry_id).""" + + @callback + def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None: + """ + Define a callback to migrate appropriate SabnzbdSensor entities to new unique IDs. + + Old: description.key + New: {entry_id}_description.key + """ + entry_id = entity_entry.config_entry_id + if entry_id is None: + return None + if entity_entry.unique_id.startswith(entry_id): + return None + + new_unique_id = f"{entry_id}_{entity_entry.unique_id}" + + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + + return {"new_unique_id": new_unique_id} + + await async_migrate_entries(hass, entry.entry_id, async_migrate_callback) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the SabNzbd Component.""" + sab_api = await get_client(hass, entry.data) if not sab_api: raise ConfigEntryNotReady @@ -137,6 +187,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEY_NAME: entry.data[CONF_NAME], } + await migrate_unique_id(hass, entry) + update_device_identifiers(hass, entry) + @callback def extract_api(func: Callable) -> Callable: """Define a decorator to get the correct api for a service call.""" @@ -188,6 +241,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error(err) async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 539eaa4f097..043a344ec7b 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -103,7 +103,19 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), ) -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] +OLD_SENSOR_KEYS = [ + "current_status", + "speed", + "queue_size", + "queue_remaining", + "disk_size", + "disk_free", + "queue_count", + "day_size", + "week_size", + "month_size", + "total_size", +] async def async_setup_entry( @@ -113,11 +125,16 @@ async def async_setup_entry( ) -> None: """Set up a Sabnzbd sensor entry.""" - sab_api_data = hass.data[DOMAIN][config_entry.entry_id][KEY_API_DATA] - client_name = hass.data[DOMAIN][config_entry.entry_id][KEY_NAME] + entry_id = config_entry.entry_id + + sab_api_data = hass.data[DOMAIN][entry_id][KEY_API_DATA] + client_name = hass.data[DOMAIN][entry_id][KEY_NAME] async_add_entities( - [SabnzbdSensor(sab_api_data, client_name, sensor) for sensor in SENSOR_TYPES] + [ + SabnzbdSensor(sab_api_data, client_name, sensor, entry_id) + for sensor in SENSOR_TYPES + ] ) @@ -128,17 +145,21 @@ class SabnzbdSensor(SensorEntity): _attr_should_poll = False def __init__( - self, sabnzbd_api_data, client_name, description: SabnzbdSensorEntityDescription + self, + sabnzbd_api_data, + client_name, + description: SabnzbdSensorEntityDescription, + entry_id, ): """Initialize the sensor.""" - unique_id = description.key - self._attr_unique_id = unique_id + + self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description self._sabnzbd_api = sabnzbd_api_data self._attr_name = f"{client_name} {description.name}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, DOMAIN)}, + identifiers={(DOMAIN, entry_id)}, name=DEFAULT_NAME, ) @@ -156,9 +177,11 @@ class SabnzbdSensor(SensorEntity): self.entity_description.key ) - if self.entity_description.key == SPEED_KEY: - self._attr_native_value = round(float(self._attr_native_value) / 1024, 1) - elif "size" in self.entity_description.key: - self._attr_native_value = round(float(self._attr_native_value), 2) - + if self._attr_native_value is not None: + if self.entity_description.key == SPEED_KEY: + self._attr_native_value = round( + float(self._attr_native_value) / 1024, 1 + ) + elif "size" in self.entity_description.key: + self._attr_native_value = round(float(self._attr_native_value), 2) self.schedule_update_ha_state() diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 804523b3390..cb1b02e37ae 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.05.0"], + "requirements": ["simplisafe-python==2022.05.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index b2b2ff4162f..587854a3a7e 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -7,10 +7,10 @@ from typing import Any import aiohttp from aiohttp import ClientSession -from ukrainealarm.client import Client +from uasiren.client import Client from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_REGION +from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,14 +24,11 @@ UPDATE_INTERVAL = timedelta(seconds=10) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ukraine Alarm as config entry.""" - api_key = entry.data[CONF_API_KEY] region_id = entry.data[CONF_REGION] websession = async_get_clientsession(hass) - coordinator = UkraineAlarmDataUpdateCoordinator( - hass, websession, api_key, region_id - ) + coordinator = UkraineAlarmDataUpdateCoordinator(hass, websession, region_id) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -56,19 +53,18 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, session: ClientSession, - api_key: str, region_id: str, ) -> None: """Initialize.""" self.region_id = region_id - self.ukrainealarm = Client(session, api_key) + self.uasiren = Client(session) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: - res = await self.ukrainealarm.get_alerts(self.region_id) + res = await self.uasiren.get_alerts(self.region_id) except aiohttp.ClientError as error: raise UpdateFailed(f"Error fetching alerts from API: {error}") from error diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index dcf41658dfb..4f1e1c5cf23 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -2,17 +2,20 @@ from __future__ import annotations import asyncio +import logging import aiohttp -from ukrainealarm.client import Client +from uasiren.client import Client import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_REGION +from homeassistant.const import CONF_NAME, CONF_REGION from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class UkraineAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Ukraine Alarm.""" @@ -21,54 +24,47 @@ class UkraineAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize a new UkraineAlarmConfigFlow.""" - self.api_key = None self.states = None self.selected_region = None async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - errors = {} - if user_input is not None: + if len(self._async_current_entries()) == 5: + return self.async_abort(reason="max_regions") + + if not self.states: websession = async_get_clientsession(self.hass) + reason = None + unknown_err_msg = None try: - regions = await Client( - websession, user_input[CONF_API_KEY] - ).get_regions() + regions = await Client(websession).get_regions() except aiohttp.ClientResponseError as ex: - errors["base"] = "invalid_api_key" if ex.status == 401 else "unknown" + if ex.status == 429: + reason = "rate_limit" + else: + reason = "unknown" + unknown_err_msg = str(ex) except aiohttp.ClientConnectionError: - errors["base"] = "cannot_connect" - except aiohttp.ClientError: - errors["base"] = "unknown" + reason = "cannot_connect" + except aiohttp.ClientError as ex: + reason = "unknown" + unknown_err_msg = str(ex) except asyncio.TimeoutError: - errors["base"] = "timeout" + reason = "timeout" - if not errors and not regions: - errors["base"] = "unknown" + if not reason and not regions: + reason = "unknown" + unknown_err_msg = "no regions returned" - if not errors: - self.api_key = user_input[CONF_API_KEY] - self.states = regions["states"] - return await self.async_step_state() + if unknown_err_msg: + _LOGGER.error("Failed to connect to the service: %s", unknown_err_msg) - schema = vol.Schema( - { - vol.Required(CONF_API_KEY): str, - } - ) + if reason: + return self.async_abort(reason=reason) + self.states = regions["states"] - return self.async_show_form( - step_id="user", - data_schema=schema, - description_placeholders={"api_url": "https://api.ukrainealarm.com/"}, - errors=errors, - last_step=False, - ) - - async def async_step_state(self, user_input=None): - """Handle user-chosen state.""" - return await self._handle_pick_region("state", "district", user_input) + return await self._handle_pick_region("user", "district", user_input) async def async_step_district(self, user_input=None): """Handle user-chosen district.""" @@ -126,7 +122,6 @@ class UkraineAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self.selected_region["regionName"], data={ - CONF_API_KEY: self.api_key, CONF_REGION: self.selected_region["regionId"], CONF_NAME: self.selected_region["regionName"], }, diff --git a/homeassistant/components/ukraine_alarm/manifest.json b/homeassistant/components/ukraine_alarm/manifest.json index 08dad9960b5..5592ac774a4 100644 --- a/homeassistant/components/ukraine_alarm/manifest.json +++ b/homeassistant/components/ukraine_alarm/manifest.json @@ -3,7 +3,7 @@ "name": "Ukraine Alarm", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ukraine_alarm", - "requirements": ["ukrainealarm==0.0.1"], + "requirements": ["uasiren==0.0.1"], "codeowners": ["@PaulAnnekov"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/ukraine_alarm/strings.json b/homeassistant/components/ukraine_alarm/strings.json index 79f81e71b08..6831d66adb3 100644 --- a/homeassistant/components/ukraine_alarm/strings.json +++ b/homeassistant/components/ukraine_alarm/strings.json @@ -1,22 +1,15 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" - }, - "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "max_regions": "Max 5 regions can be configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "rate_limit": "Too much requests", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]", "timeout": "[%key:common::config_flow::error::timeout_connect%]" }, "step": { "user": { - "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" - }, - "description": "Set up the Ukraine Alarm integration. To generate an API key go to {api_url}" - }, - "state": { "data": { "region": "Region" }, @@ -24,13 +17,13 @@ }, "district": { "data": { - "region": "[%key:component::ukraine_alarm::config::step::state::data::region%]" + "region": "[%key:component::ukraine_alarm::config::step::user::data::region%]" }, "description": "If you want to monitor not only state, choose its specific district" }, "community": { "data": { - "region": "[%key:component::ukraine_alarm::config::step::state::data::region%]" + "region": "[%key:component::ukraine_alarm::config::step::user::data::region%]" }, "description": "If you want to monitor not only state and district, choose its specific community" } diff --git a/homeassistant/components/ukraine_alarm/translations/en.json b/homeassistant/components/ukraine_alarm/translations/en.json index 2c39945cb87..857311ea3e7 100644 --- a/homeassistant/components/ukraine_alarm/translations/en.json +++ b/homeassistant/components/ukraine_alarm/translations/en.json @@ -1,15 +1,19 @@ { "config": { + "abort": { + "already_configured": "Location is already configured", + "cannot_connect": "Failed to connect", + "max_regions": "Max 5 regions can be configured", + "rate_limit": "Too much requests", + "timeout": "Timeout establishing connection", + "unknown": "Unexpected error" + }, "step": { - "user": { - "description": "Set up the Ukraine Alarm integration. To generate an API key go to {api_url}", - "title": "Ukraine Alarm" - }, - "state": { + "community": { "data": { "region": "Region" }, - "description": "Choose state to monitor" + "description": "If you want to monitor not only state and district, choose its specific community" }, "district": { "data": { @@ -17,12 +21,12 @@ }, "description": "If you want to monitor not only state, choose its specific district" }, - "community": { + "user": { "data": { "region": "Region" }, - "description": "If you want to monitor not only state and district, choose its specific community" + "description": "Choose state to monitor" } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d5dbb51ffc1..3c3b461ed4f 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.4.0", "unifi-discovery==1.1.2"], + "requirements": ["pyunifiprotect==3.4.1", "unifi-discovery==1.1.2"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 906efb2c4f9..f17ddccf03c 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -8,6 +8,12 @@ from zwave_js_server.const import ConfigurationValueType from zwave_js_server.model.node import Node from zwave_js_server.model.value import ConfigurationValue +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + NODE_STATUSES = ["asleep", "awake", "dead", "alive"] CONF_SUBTYPE = "subtype" @@ -41,3 +47,21 @@ def generate_config_parameter_subtype(config_value: ConfigurationValue) -> str: parameter = f"{parameter}[{hex(config_value.property_key)}]" return f"{parameter} ({config_value.property_name})" + + +@callback +def async_bypass_dynamic_config_validation(hass: HomeAssistant, device_id: str) -> bool: + """Return whether device's config entries are not loaded.""" + dev_reg = dr.async_get(hass) + if (device := dev_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} not found") + entry = next( + ( + config_entry + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id in device.config_entries + and config_entry.state == ConfigEntryState.LOADED + ), + None, + ) + return not entry diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index c70371d6f8a..549319d23f4 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -29,12 +29,12 @@ from .device_automation_helpers import ( CONF_SUBTYPE, CONF_VALUE_ID, NODE_STATUSES, + async_bypass_dynamic_config_validation, generate_config_parameter_subtype, get_config_parameter_value_schema, ) from .helpers import ( async_get_node_from_device_id, - async_is_device_config_entry_not_loaded, check_type_schema_map, get_zwave_value_from_config, remove_keys_with_empty_values, @@ -101,7 +101,7 @@ async def async_validate_condition_config( # We return early if the config entry for this device is not ready because we can't # validate the value without knowing the state of the device try: - device_config_entry_not_loaded = async_is_device_config_entry_not_loaded( + bypass_dynamic_config_validation = async_bypass_dynamic_config_validation( hass, config[CONF_DEVICE_ID] ) except ValueError as err: @@ -109,7 +109,7 @@ async def async_validate_condition_config( f"Device {config[CONF_DEVICE_ID]} not found" ) from err - if device_config_entry_not_loaded: + if bypass_dynamic_config_validation: return config if config[CONF_TYPE] == VALUE_TYPE: diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 89379f9a953..0b6369654fe 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -53,12 +53,12 @@ from .const import ( from .device_automation_helpers import ( CONF_SUBTYPE, NODE_STATUSES, + async_bypass_dynamic_config_validation, generate_config_parameter_subtype, ) from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, - async_is_device_config_entry_not_loaded, check_type_schema_map, copy_available_params, get_value_state_schema, @@ -215,7 +215,7 @@ async def async_validate_trigger_config( # We return early if the config entry for this device is not ready because we can't # validate the value without knowing the state of the device try: - device_config_entry_not_loaded = async_is_device_config_entry_not_loaded( + bypass_dynamic_config_validation = async_bypass_dynamic_config_validation( hass, config[CONF_DEVICE_ID] ) except ValueError as err: @@ -223,7 +223,7 @@ async def async_validate_trigger_config( f"Device {config[CONF_DEVICE_ID]} not found" ) from err - if device_config_entry_not_loaded: + if bypass_dynamic_config_validation: return config trigger_type = config[CONF_TYPE] diff --git a/homeassistant/const.py b/homeassistant/const.py index 569c121f909..5eb59e819da 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 = 5 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __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/requirements_all.txt b/requirements_all.txt index 15ce8f1c8ce..ef48c8117f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -957,7 +957,7 @@ lmnotify==0.0.4 locationsharinglib==4.1.5 # homeassistant.components.logi_circle -logi_circle==0.2.2 +logi_circle==0.2.3 # homeassistant.components.london_underground london-tube-status==0.2 @@ -1065,7 +1065,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.3 +nettigo-air-monitor==1.2.4 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1399,7 +1399,7 @@ pycfdns==1.2.2 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==12.1.1 +pychromecast==12.1.2 # homeassistant.components.pocketcasts pycketcasts==1.0.0 @@ -1432,7 +1432,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==91 +pydeconz==92 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1981,7 +1981,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.4.0 +pyunifiprotect==3.4.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2153,7 +2153,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.05.0 +simplisafe-python==2022.05.1 # homeassistant.components.sisyphus sisyphus-control==3.1.2 @@ -2343,7 +2343,7 @@ twitchAPI==2.5.2 uEagle==0.0.2 # homeassistant.components.ukraine_alarm -ukrainealarm==0.0.1 +uasiren==0.0.1 # homeassistant.components.unifiprotect unifi-discovery==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eeb9f18fbfa..cb683ae0bab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -655,7 +655,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.logi_circle -logi_circle==0.2.2 +logi_circle==0.2.3 # homeassistant.components.recorder lru-dict==1.1.7 @@ -727,7 +727,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.3 +nettigo-air-monitor==1.2.4 # homeassistant.components.nexia nexia==0.9.13 @@ -938,7 +938,7 @@ pybotvac==0.0.23 pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==12.1.1 +pychromecast==12.1.2 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -953,7 +953,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==91 +pydeconz==92 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1304,7 +1304,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.4.0 +pyunifiprotect==3.4.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1404,7 +1404,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2022.05.0 +simplisafe-python==2022.05.1 # homeassistant.components.slack slackclient==2.5.0 @@ -1525,7 +1525,7 @@ twitchAPI==2.5.2 uEagle==0.0.2 # homeassistant.components.ukraine_alarm -ukrainealarm==0.0.1 +uasiren==0.0.1 # homeassistant.components.unifiprotect unifi-discovery==1.1.2 diff --git a/setup.cfg b/setup.cfg index 2ec80dd2855..b492bd0a240 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = homeassistant -version = 2022.5.3 +version = 2022.5.4 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 diff --git a/tests/components/cast/fixtures/rthkaudio2.m3u8 b/tests/components/cast/fixtures/rthkaudio2.m3u8 new file mode 100644 index 00000000000..388c115635f --- /dev/null +++ b/tests/components/cast/fixtures/rthkaudio2.m3u8 @@ -0,0 +1,5 @@ +#EXTM3U +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=54000,CODECS="mp4a.40.2" +https://rthkaudio2-lh.akamaihd.net/i/radio2_1@355865/index_56_a-p.m3u8?sd=10&rebase=on +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=54000,CODECS="mp4a.40.2" +https://rthkaudio2-lh.akamaihd.net/i/radio2_1@355865/index_56_a-b.m3u8?sd=10&rebase=on diff --git a/tests/components/cast/test_helpers.py b/tests/components/cast/test_helpers.py index 0d7a3b1ff14..d729d36a225 100644 --- a/tests/components/cast/test_helpers.py +++ b/tests/components/cast/test_helpers.py @@ -14,10 +14,25 @@ from homeassistant.components.cast.helpers import ( from tests.common import load_fixture -async def test_hls_playlist_supported(hass, aioclient_mock): +@pytest.mark.parametrize( + "url,fixture,content_type", + ( + ( + "http://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_radio_fourfm.m3u8", + "bbc_radio_fourfm.m3u8", + None, + ), + ( + "https://rthkaudio2-lh.akamaihd.net/i/radio2_1@355865/master.m3u8", + "rthkaudio2.m3u8", + "application/vnd.apple.mpegurl", + ), + ), +) +async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, content_type): """Test playlist parsing of HLS playlist.""" - url = "http://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_radio_fourfm.m3u8" - aioclient_mock.get(url, text=load_fixture("bbc_radio_fourfm.m3u8", "cast")) + headers = {"content-type": content_type} + aioclient_mock.get(url, text=load_fixture(fixture, "cast"), headers=headers) with pytest.raises(PlaylistSupported): await parse_playlist(hass, url) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 457cac26aa5..dd53cb8548e 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -18,6 +18,7 @@ from homeassistant.components.generic.const import ( CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DOMAIN, ) from homeassistant.const import ( @@ -653,3 +654,32 @@ async def test_migrate_existing_ids(hass) -> None: entity_entry = registry.async_get(entity_id) assert entity_entry.unique_id == new_unique_id + + +@respx.mock +async def test_use_wallclock_as_timestamps_option(hass, fakeimg_png, mock_av_open): + """Test the use_wallclock_as_timestamps option flow.""" + + mock_entry = MockConfigEntry( + title="Test Camera", + domain=DOMAIN, + data={}, + options=TESTDATA, + ) + + with mock_av_open: + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + mock_entry.entry_id, context={"show_advanced_options": True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index bfa0c8f415e..4f56edaa291 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1387,3 +1387,117 @@ async def test_measure_cet(hass, recorder_mock): assert hass.states.get("sensor.sensor2").state == "0.83" assert hass.states.get("sensor.sensor3").state == "1" assert hass.states.get("sensor.sensor4").state == "83.3" + + +@pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) +async def test_end_time_with_microseconds_zeroed(time_zone, hass, recorder_mock): + """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_time = start_of_today + timedelta(minutes=60) + t0 = start_time + timedelta(minutes=20) + t1 = t0 + timedelta(minutes=10) + t2 = t1 + timedelta(minutes=10) + time_200 = start_of_today + timedelta(hours=2) + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.heatpump_compressor_state": [ + ha.State( + "binary_sensor.heatpump_compressor_state", "on", last_changed=t0 + ), + ha.State( + "binary_sensor.heatpump_compressor_state", + "off", + last_changed=t1, + ), + ha.State( + "binary_sensor.heatpump_compressor_state", "on", last_changed=t2 + ), + ] + } + + with freeze_time(time_200), patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.heatpump_compressor_state", + "name": "heatpump_compressor_today", + "state": "on", + "start": "{{ now().replace(hour=0, minute=0, second=0, microsecond=0) }}", + "end": "{{ now().replace(microsecond=0) }}", + "type": "time", + }, + ] + }, + ) + await hass.async_block_till_done() + await async_update_entity(hass, "sensor.heatpump_compressor_today") + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + async_fire_time_changed(hass, time_200) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + hass.states.async_set("binary_sensor.heatpump_compressor_state", "off") + await hass.async_block_till_done() + + time_400 = start_of_today + timedelta(hours=4) + with freeze_time(time_400): + async_fire_time_changed(hass, time_400) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + hass.states.async_set("binary_sensor.heatpump_compressor_state", "on") + await hass.async_block_till_done() + time_600 = start_of_today + timedelta(hours=6) + with freeze_time(time_600): + async_fire_time_changed(hass, time_600) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "3.83" + + rolled_to_next_day = start_of_today + timedelta(days=1) + assert rolled_to_next_day.hour == 0 + assert rolled_to_next_day.minute == 0 + assert rolled_to_next_day.second == 0 + assert rolled_to_next_day.microsecond == 0 + + with freeze_time(rolled_to_next_day): + async_fire_time_changed(hass, rolled_to_next_day) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "0.0" + + rolled_to_next_day_plus_12 = start_of_today + timedelta( + days=1, hours=12, microseconds=0 + ) + with freeze_time(rolled_to_next_day_plus_12): + async_fire_time_changed(hass, rolled_to_next_day_plus_12) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "12.0" + + rolled_to_next_day_plus_14 = start_of_today + timedelta( + days=1, hours=14, microseconds=0 + ) + with freeze_time(rolled_to_next_day_plus_14): + async_fire_time_changed(hass, rolled_to_next_day_plus_14) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "14.0" + + rolled_to_next_day_plus_16_860000 = start_of_today + timedelta( + days=1, hours=16, microseconds=860000 + ) + with freeze_time(rolled_to_next_day_plus_16_860000): + hass.states.async_set("binary_sensor.heatpump_compressor_state", "off") + async_fire_time_changed(hass, rolled_to_next_day_plus_16_860000) + await hass.async_block_till_done() + + rolled_to_next_day_plus_18 = start_of_today + timedelta(days=1, hours=18) + with freeze_time(rolled_to_next_day_plus_18): + async_fire_time_changed(hass, rolled_to_next_day_plus_18) + await hass.async_block_till_done() + assert hass.states.get("sensor.heatpump_compressor_today").state == "16.0" diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index d04c5b18ab1..bc72dff2535 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -87,7 +87,6 @@ async def test_import_flow(hass) -> None: "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py new file mode 100644 index 00000000000..f140c332778 --- /dev/null +++ b/tests/components/sabnzbd/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the SABnzbd Integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.sabnzbd import DEFAULT_NAME, DOMAIN, OLD_SENSOR_KEYS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType + +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +MOCK_ENTRY_ID = "mock_entry_id" + +MOCK_UNIQUE_ID = "someuniqueid" + +MOCK_DEVICE_ID = "somedeviceid" + +MOCK_DATA_VERSION_1 = { + CONF_API_KEY: "api_key", + CONF_URL: "http://127.0.0.1:8080", + CONF_NAME: "name", +} + +MOCK_ENTRY_VERSION_1 = MockConfigEntry( + domain=DOMAIN, data=MOCK_DATA_VERSION_1, entry_id=MOCK_ENTRY_ID, version=1 +) + + +@pytest.fixture +def device_registry(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_registry(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +async def test_unique_id_migrate(hass, device_registry, entity_registry): + """Test that config flow entry is migrated correctly.""" + # Start with the config entry at Version 1. + mock_entry = MOCK_ENTRY_VERSION_1 + mock_entry.add_to_hass(hass) + + mock_d_entry = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, DOMAIN)}, + name=DEFAULT_NAME, + entry_type=DeviceEntryType.SERVICE, + ) + + entity_id_sensor_key = [] + + for sensor_key in OLD_SENSOR_KEYS: + mock_entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{sensor_key}" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id=sensor_key, + config_entry=mock_entry, + device_id=mock_d_entry.id, + ) + entity = entity_registry.async_get(mock_entity_id) + assert entity.entity_id == mock_entity_id + assert entity.unique_id == sensor_key + entity_id_sensor_key.append((mock_entity_id, sensor_key)) + + with patch( + "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", + return_value=True, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + + await hass.async_block_till_done() + + for mock_entity_id, sensor_key in entity_id_sensor_key: + entity = entity_registry.async_get(mock_entity_id) + assert entity.unique_id == f"{MOCK_ENTRY_ID}_{sensor_key}" + + assert device_registry.async_get(mock_d_entry.id).identifiers == { + (DOMAIN, MOCK_ENTRY_ID) + } diff --git a/tests/components/ukraine_alarm/test_config_flow.py b/tests/components/ukraine_alarm/test_config_flow.py index 3832e6a9fb6..7369816fdc7 100644 --- a/tests/components/ukraine_alarm/test_config_flow.py +++ b/tests/components/ukraine_alarm/test_config_flow.py @@ -3,15 +3,20 @@ import asyncio from collections.abc import Generator from unittest.mock import AsyncMock, patch -from aiohttp import ClientConnectionError, ClientError, ClientResponseError +from aiohttp import ClientConnectionError, ClientError, ClientResponseError, RequestInfo import pytest +from yarl import URL from homeassistant import config_entries from homeassistant.components.ukraine_alarm.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) -MOCK_API_KEY = "mock-api-key" +from tests.common import MockConfigEntry def _region(rid, recurse=0, depth=0): @@ -57,12 +62,7 @@ async def test_state(hass: HomeAssistant) -> None: ) assert result["type"] == RESULT_TYPE_FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result2["type"] == RESULT_TYPE_FORM with patch( @@ -80,7 +80,6 @@ async def test_state(hass: HomeAssistant) -> None: assert result3["type"] == RESULT_TYPE_CREATE_ENTRY assert result3["title"] == "State 1" assert result3["data"] == { - "api_key": MOCK_API_KEY, "region": "1", "name": result3["title"], } @@ -94,12 +93,7 @@ async def test_state_district(hass: HomeAssistant) -> None: ) assert result["type"] == RESULT_TYPE_FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result2["type"] == RESULT_TYPE_FORM result3 = await hass.config_entries.flow.async_configure( @@ -125,7 +119,6 @@ async def test_state_district(hass: HomeAssistant) -> None: assert result4["type"] == RESULT_TYPE_CREATE_ENTRY assert result4["title"] == "District 2.2" assert result4["data"] == { - "api_key": MOCK_API_KEY, "region": "2.2", "name": result4["title"], } @@ -139,12 +132,7 @@ async def test_state_district_pick_region(hass: HomeAssistant) -> None: ) assert result["type"] == RESULT_TYPE_FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result2["type"] == RESULT_TYPE_FORM result3 = await hass.config_entries.flow.async_configure( @@ -170,7 +158,6 @@ async def test_state_district_pick_region(hass: HomeAssistant) -> None: assert result4["type"] == RESULT_TYPE_CREATE_ENTRY assert result4["title"] == "State 2" assert result4["data"] == { - "api_key": MOCK_API_KEY, "region": "2", "name": result4["title"], } @@ -186,9 +173,6 @@ async def test_state_district_community(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, ) assert result2["type"] == RESULT_TYPE_FORM @@ -223,132 +207,89 @@ async def test_state_district_community(hass: HomeAssistant) -> None: assert result5["type"] == RESULT_TYPE_CREATE_ENTRY assert result5["title"] == "Community 3.2.1" assert result5["data"] == { - "api_key": MOCK_API_KEY, "region": "3.2.1", "name": result5["title"], } assert len(mock_setup_entry.mock_calls) == 1 -async def test_invalid_api(hass: HomeAssistant, mock_get_regions: AsyncMock) -> None: - """Test we can create entry for just region.""" +async def test_max_regions(hass: HomeAssistant) -> None: + """Test max regions config.""" + for i in range(5): + MockConfigEntry( + domain=DOMAIN, + unique_id=i, + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - mock_get_regions.side_effect = ClientResponseError(None, None, status=401) + assert result["type"] == "abort" + assert result["reason"] == "max_regions" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, + +async def test_rate_limit(hass: HomeAssistant, mock_get_regions: AsyncMock) -> None: + """Test rate limit error.""" + mock_get_regions.side_effect = ClientResponseError(None, None, status=429) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_api_key"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "rate_limit" async def test_server_error(hass: HomeAssistant, mock_get_regions) -> None: - """Test we can create entry for just region.""" + """Test server error.""" + mock_get_regions.side_effect = ClientResponseError( + RequestInfo(None, None, None, real_url=URL("/regions")), None, status=500 + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - - mock_get_regions.side_effect = ClientResponseError(None, None, status=500) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" async def test_cannot_connect(hass: HomeAssistant, mock_get_regions: AsyncMock) -> None: - """Test we can create entry for just region.""" + """Test connection error.""" + mock_get_regions.side_effect = ClientConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - - mock_get_regions.side_effect = ClientConnectionError - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" async def test_unknown_client_error( hass: HomeAssistant, mock_get_regions: AsyncMock ) -> None: - """Test we can create entry for just region.""" + """Test client error.""" + mock_get_regions.side_effect = ClientError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - - mock_get_regions.side_effect = ClientError - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" async def test_timeout_error(hass: HomeAssistant, mock_get_regions: AsyncMock) -> None: - """Test we can create entry for just region.""" + """Test timeout error.""" + mock_get_regions.side_effect = asyncio.TimeoutError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - - mock_get_regions.side_effect = asyncio.TimeoutError - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "timeout"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "timeout" async def test_no_regions_returned( hass: HomeAssistant, mock_get_regions: AsyncMock ) -> None: - """Test we can create entry for just region.""" + """Test regions not returned.""" + mock_get_regions.return_value = {} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - - mock_get_regions.return_value = {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "api_key": MOCK_API_KEY, - }, - ) - assert result2["type"] == RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown"