diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 4b97c8dc21a..3d064f47e97 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -117,6 +117,11 @@ async def async_setup_entry( async_add_entities([DaikinClimate(daikin_api)], update_before_add=True) +def format_target_temperature(target_temperature): + """Format target temperature to be sent to the Daikin unit, taking care of keeping at most 1 decimal digit.""" + return str(round(float(target_temperature), 1)).rstrip("0").rstrip(".") + + class DaikinClimate(ClimateEntity): """Representation of a Daikin HVAC.""" @@ -163,9 +168,9 @@ class DaikinClimate(ClimateEntity): # temperature elif attr == ATTR_TEMPERATURE: try: - values[HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]] = str( - round(float(value), 1) - ) + values[ + HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE] + ] = format_target_temperature(value) except ValueError: _LOGGER.error("Invalid temperature %s", value) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index fb9ebc70408..e540d077781 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,7 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "documentation": "https://www.home-assistant.io/integrations/dhcp", - "requirements": ["scapy==2.4.5", "aiodiscover==1.4.8"], + "requirements": ["scapy==2.4.5", "aiodiscover==1.4.9"], "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push", diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 3fed62e961e..2536e5a8de0 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -351,21 +351,11 @@ async def async_wait_for_elk_to_sync( login_event.set() sync_event.set() - def first_response(*args, **kwargs): - _LOGGER.debug("ElkM1 received first response (VN)") - login_event.set() - def sync_complete(): sync_event.set() success = True elk.add_handler("login", login_status) - # VN is the first command sent for panel, when we get - # it back we now we are logged in either with or without a password - elk.add_handler("VN", first_response) - # Some panels do not respond to the vn request so we - # check for lw as well - elk.add_handler("LW", first_response) elk.add_handler("sync_complete", sync_complete) for name, event, timeout in ( ("login", login_event, login_timeout), diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 695b6bcd999..ceea8e92ca5 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==1.2.0"], + "requirements": ["elkm1-lib==1.2.2"], "dhcp": [{ "registered_devices": true }, { "macaddress": "00409D*" }], "codeowners": ["@gwww", "@bdraco"], "dependencies": ["network"], diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 97fe5f5511d..22b8cd60d79 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -137,9 +137,10 @@ class FileSizeCoordinator(DataUpdateCoordinator): raise UpdateFailed(f"Can not retrieve file statistics {error}") from error size = statinfo.st_size - last_updated = datetime.fromtimestamp(statinfo.st_mtime).replace( + last_updated = datetime.utcfromtimestamp(statinfo.st_mtime).replace( tzinfo=dt_util.UTC ) + _LOGGER.debug("size %s, last updated %s", size, last_updated) data: dict[str, int | float | datetime] = { "file": round(size / 1e6, 2), diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index c6310d22dce..55541a6ea68 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -8,13 +8,13 @@ import io import logging from types import MappingProxyType from typing import Any -from urllib.parse import urlparse, urlunparse import PIL from async_timeout import timeout import av from httpx import HTTPStatusError, RequestError, TimeoutException import voluptuous as vol +import yarl from homeassistant.components.stream.const import SOURCE_TIMEOUT from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow @@ -109,20 +109,6 @@ def build_schema( return vol.Schema(spec) -def build_schema_content_type(user_input: dict[str, Any] | MappingProxyType[str, Any]): - """Create schema for conditional 2nd page specifying stream content_type.""" - return vol.Schema( - { - vol.Required( - CONF_CONTENT_TYPE, - description={ - "suggested_value": user_input.get(CONF_CONTENT_TYPE, "image/jpeg") - }, - ): str, - } - ) - - def get_image_type(image): """Get the format of downloaded bytes that could be an image.""" fmt = None @@ -186,8 +172,7 @@ def slug_url(url) -> str | None: """Convert a camera url into a string suitable for a camera name.""" if not url: return None - url_no_scheme = urlparse(url)._replace(scheme="") - return slugify(urlunparse(url_no_scheme).strip("/")) + return slugify(yarl.URL(url).host) async def async_test_stream(hass, info) -> dict[str, str]: @@ -283,17 +268,16 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_CONTENT_TYPE] = still_format user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - if user_input.get(CONF_STILL_IMAGE_URL): - await self.async_set_unique_id(self.flow_id) - return self.async_create_entry( - title=name, data={}, options=user_input - ) - # If user didn't specify a still image URL, - # we can't (yet) autodetect it from the stream. - # Show a conditional 2nd page to ask them the content type. - self.cached_user_input = user_input - self.cached_title = name - return await self.async_step_content_type() + if still_url is None: + # If user didn't specify a still image URL, + # The automatically generated still image that stream generates + # is always jpeg + user_input[CONF_CONTENT_TYPE] = "image/jpeg" + + await self.async_set_unique_id(self.flow_id) + return self.async_create_entry( + title=name, data={}, options=user_input + ) else: user_input = DEFAULT_DATA.copy() @@ -303,22 +287,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_content_type( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the user's choice for stream content_type.""" - if user_input is not None: - user_input = self.cached_user_input | user_input - await self.async_set_unique_id(self.flow_id) - return self.async_create_entry( - title=self.cached_title, data={}, options=user_input - ) - return self.async_show_form( - step_id="content_type", - data_schema=build_schema_content_type({}), - errors={}, - ) - async def async_step_import(self, import_config) -> FlowResult: """Handle config import from yaml.""" # abort if we've already got this one. @@ -362,6 +330,11 @@ class GenericOptionsFlowHandler(OptionsFlow): stream_url = user_input.get(CONF_STREAM_SOURCE) if not errors: title = slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME + if still_url is None: + # If user didn't specify a still image URL, + # The automatically generated still image that stream generates + # is always jpeg + still_format = "image/jpeg" data = { CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION), CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE), @@ -376,30 +349,12 @@ class GenericOptionsFlowHandler(OptionsFlow): CONF_FRAMERATE: user_input[CONF_FRAMERATE], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], } - if still_url: - return self.async_create_entry( - title=title, - data=data, - ) - self.cached_title = title - self.cached_user_input = data - return await self.async_step_content_type() - + return self.async_create_entry( + title=title, + data=data, + ) return self.async_show_form( step_id="init", data_schema=build_schema(user_input or self.config_entry.options, True), errors=errors, ) - - async def async_step_content_type( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the user's choice for stream content_type.""" - if user_input is not None: - user_input = self.cached_user_input | user_input - return self.async_create_entry(title=self.cached_title, data=user_input) - return self.async_show_form( - step_id="content_type", - data_schema=build_schema_content_type(self.cached_user_input), - errors={}, - ) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 1174868bb78..f9c05d3c846 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -120,13 +120,20 @@ class GoogleCalendarEventDevice(CalendarEventDevice): self._event: dict[str, Any] | None = None self._name: str = data[CONF_NAME] self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) - self._offset_reached = False + self._offset_value: timedelta | None = None self.entity_id = entity_id @property def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" - return {"offset_reached": self._offset_reached} + return {"offset_reached": self.offset_reached} + + @property + def offset_reached(self) -> bool: + """Return whether or not the event offset was reached.""" + if self._event and self._offset_value: + return is_offset_reached(get_date(self._event["start"]), self._offset_value) + return False @property def event(self) -> dict[str, Any] | None: @@ -187,6 +194,4 @@ class GoogleCalendarEventDevice(CalendarEventDevice): self._event.get("summary", ""), self._offset ) self._event["summary"] = summary - self._offset_reached = is_offset_reached( - get_date(self._event["start"]), offset - ) + self._offset_value = offset diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9ca447ad2fe..ae9b1261bc8 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.7.16"], + "requirements": ["aiohomekit==0.7.17"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index d1f33387a15..32d78c5c8dd 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -46,10 +46,12 @@ _LOGGER = logging.getLogger(__name__) # Estimated time it takes to complete a transition # from one state to another -TRANSITION_COMPLETE_DURATION = 30 +TRANSITION_COMPLETE_DURATION = 40 PARALLEL_UPDATES = 1 +RESYNC_DELAY = 60 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -103,6 +105,9 @@ def hass_position_to_hd(hass_position): class PowerViewShade(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" + # The hub frequently reports stale states + _attr_assumed_state = True + def __init__(self, coordinator, device_info, room_name, shade, name): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) @@ -112,6 +117,7 @@ class PowerViewShade(ShadeEntity, CoverEntity): self._last_action_timestamp = 0 self._scheduled_transition_update = None self._current_cover_position = MIN_POSITION + self._forced_resync = None @property def extra_state_attributes(self): @@ -224,10 +230,12 @@ class PowerViewShade(ShadeEntity, CoverEntity): @callback def _async_cancel_scheduled_transition_update(self): """Cancel any previous updates.""" - if not self._scheduled_transition_update: - return - self._scheduled_transition_update() - self._scheduled_transition_update = None + if self._scheduled_transition_update: + self._scheduled_transition_update() + self._scheduled_transition_update = None + if self._forced_resync: + self._forced_resync() + self._forced_resync = None @callback def _async_schedule_update_for_transition(self, steps): @@ -260,6 +268,14 @@ class PowerViewShade(ShadeEntity, CoverEntity): _LOGGER.debug("Processing scheduled update for %s", self.name) self._scheduled_transition_update = None await self._async_force_refresh_state() + self._forced_resync = async_call_later( + self.hass, RESYNC_DELAY, self._async_force_resync + ) + + async def _async_force_resync(self, *_): + """Force a resync after an update since the hub may have stale state.""" + self._forced_resync = None + await self._async_force_refresh_state() async def _async_force_refresh_state(self): """Refresh the cover state and force the device cache to be bypassed.""" @@ -274,10 +290,14 @@ class PowerViewShade(ShadeEntity, CoverEntity): self.coordinator.async_add_listener(self._async_update_shade_from_group) ) + async def async_will_remove_from_hass(self): + """Cancel any pending refreshes.""" + self._async_cancel_scheduled_transition_update() + @callback def _async_update_shade_from_group(self): """Update with new data from the coordinator.""" - if self._scheduled_transition_update: + if self._scheduled_transition_update or self._forced_resync: # If a transition in in progress # the data will be wrong return diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index d9081c5b45e..5bf8769f321 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -1,19 +1,24 @@ """Test config flow for Insteon.""" +from __future__ import annotations + import logging from pyinsteon import async_connect import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import usb from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICE, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -107,6 +112,9 @@ def _remove_x10(device, options): class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Insteon config flow handler.""" + _device_path: str | None = None + _device_name: str | None = None + @staticmethod @callback def async_get_options_flow(config_entry): @@ -177,6 +185,38 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") return self.async_create_entry(title="", data=import_info) + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: + """Handle USB discovery.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + self._device_path = dev_path + self._device_name = usb.human_readable_device_name( + dev_path, + discovery_info.serial_number, + discovery_info.manufacturer, + discovery_info.description, + discovery_info.vid, + discovery_info.pid, + ) + self._set_confirm_only() + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + await self.async_set_unique_id(config_entries.DEFAULT_DISCOVERY_UNIQUE_ID) + return await self.async_step_confirm_usb() + + async def async_step_confirm_usb(self, user_input=None): + """Confirm a discovery.""" + if user_input is not None: + return await self.async_step_plm({CONF_DEVICE: self._device_path}) + + return self.async_show_form( + step_id="confirm_usb", + description_placeholders={CONF_NAME: self._device_name}, + ) + class InsteonOptionsFlowHandler(config_entries.OptionsFlow): """Handle an Insteon options flow.""" diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index e9f5e60f9f8..63eb24ee453 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -6,5 +6,11 @@ "codeowners": ["@teharris1"], "config_flow": true, "iot_class": "local_push", - "loggers": ["pyinsteon", "pypubsub"] + "loggers": ["pyinsteon", "pypubsub"], + "after_dependencies": ["usb"], + "usb": [ + { + "vid": "10BF" + } + ] } diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index ca88b43956f..793a38a2694 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "description": "Select the Insteon modem type.", @@ -31,6 +32,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "confirm_usb": { + "description": "Do you want to setup {name}?" } }, "error": { diff --git a/homeassistant/components/insteon/translations/en.json b/homeassistant/components/insteon/translations/en.json index 18217bb2842..4c4a439b938 100644 --- a/homeassistant/components/insteon/translations/en.json +++ b/homeassistant/components/insteon/translations/en.json @@ -8,7 +8,11 @@ "cannot_connect": "Failed to connect", "select_single": "Select one option." }, + "flow_title": "{name}", "step": { + "confirm_usb": { + "description": "Do you want to setup {name}?" + }, "hubv1": { "data": { "host": "IP Address", diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index ebd9e041332..d0561bc47ab 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -12,7 +12,7 @@ from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import ATTR_SUGGESTED_AREA, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -41,6 +41,7 @@ from .const import ( DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, + UNASSIGNED_AREA, ) from .device_trigger import ( DEVICE_TYPE_SUBTYPE_MAP_TO_LIP, @@ -195,22 +196,31 @@ def _async_register_button_devices( if "serial" not in device or device["serial"] in seen: continue seen.add(device["serial"]) + device_args = { + "name": device["name"], + "manufacturer": MANUFACTURER, + "config_entry_id": config_entry_id, + "identifiers": {(DOMAIN, device["serial"])}, + "model": f"{device['model']} ({device['type']})", + "via_device": (DOMAIN, bridge_device["serial"]), + } + area, _ = _area_and_name_from_name(device["name"]) + if area != UNASSIGNED_AREA: + device_args["suggested_area"] = area - dr_device = device_registry.async_get_or_create( - name=device["name"], - suggested_area=device["name"].split("_")[0], - manufacturer=MANUFACTURER, - config_entry_id=config_entry_id, - identifiers={(DOMAIN, device["serial"])}, - model=f"{device['model']} ({device['type']})", - via_device=(DOMAIN, bridge_device["serial"]), - ) - + dr_device = device_registry.async_get_or_create(**device_args) button_devices_by_dr_id[dr_device.id] = device return button_devices_by_dr_id +def _area_and_name_from_name(device_name: str) -> tuple[str, str]: + """Return the area and name from the devices internal name.""" + if "_" in device_name: + return device_name.split("_", 1) + return UNASSIGNED_AREA, device_name + + @callback def _async_subscribe_pico_remote_events( hass: HomeAssistant, @@ -230,7 +240,7 @@ def _async_subscribe_pico_remote_events( action = ACTION_RELEASE type_ = device["type"] - area, name = device["name"].split("_", 1) + area, name = _area_and_name_from_name(device["name"]) button_number = device["button_number"] # The original implementation used LIP instead of LEAP # so we need to convert the button number to maintain compat @@ -322,15 +332,18 @@ class LutronCasetaDevice(Entity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - return DeviceInfo( + device = self._device + info = DeviceInfo( identifiers={(DOMAIN, self.serial)}, manufacturer=MANUFACTURER, - model=f"{self._device['model']} ({self._device['type']})", + model=f"{device['model']} ({device['type']})", name=self.name, - suggested_area=self._device["name"].split("_")[0], via_device=(DOMAIN, self._bridge_device["serial"]), configuration_url="https://device-login.lutron.com", ) + area, _ = _area_and_name_from_name(device["name"]) + if area != UNASSIGNED_AREA: + info[ATTR_SUGGESTED_AREA] = area @property def extra_state_attributes(self): diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 90303dc0023..56a3821dd64 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -33,3 +33,5 @@ ACTION_RELEASE = "release" CONF_SUBTYPE = "subtype" BRIDGE_TIMEOUT = 35 + +UNASSIGNED_AREA = "Unassigned" diff --git a/homeassistant/components/overkiz/cover_entities/awning.py b/homeassistant/components/overkiz/cover_entities/awning.py index ebbff8710f3..19422d9c193 100644 --- a/homeassistant/components/overkiz/cover_entities/awning.py +++ b/homeassistant/components/overkiz/cover_entities/awning.py @@ -14,7 +14,12 @@ from homeassistant.components.cover import ( CoverDeviceClass, ) -from .generic_cover import COMMANDS_STOP, OverkizGenericCover +from .generic_cover import ( + COMMANDS_CLOSE, + COMMANDS_OPEN, + COMMANDS_STOP, + OverkizGenericCover, +) class Awning(OverkizGenericCover): @@ -67,3 +72,35 @@ class Awning(OverkizGenericCover): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.executor.async_execute_command(OverkizCommand.UNDEPLOY) + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening or not.""" + if self.is_running(COMMANDS_OPEN): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) < cast(int, target_closure.value) + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + if self.is_running(COMMANDS_CLOSE): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) > cast(int, target_closure.value) diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index c25cd1ab806..6e927d52ecf 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -14,7 +14,8 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) -from homeassistant.components.overkiz.entity import OverkizEntity + +from ..entity import OverkizEntity ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" @@ -111,55 +112,13 @@ class OverkizGenericCover(OverkizEntity, CoverEntity): if command := self.executor.select_command(*COMMANDS_STOP_TILT): await self.executor.async_execute_command(command) - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not.""" - - if self.assumed_state: - return None - - # Check if cover movement execution is currently running - if any( + def is_running(self, commands: list[OverkizCommand]) -> bool: + """Return if the given commands are currently running.""" + return any( execution.get("device_url") == self.device.device_url - and execution.get("command_name") in COMMANDS_OPEN + COMMANDS_OPEN_TILT + and execution.get("command_name") in commands for execution in self.coordinator.executions.values() - ): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) > cast(int, target_closure.value) - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not.""" - - if self.assumed_state: - return None - - # Check if cover movement execution is currently running - if any( - execution.get("device_url") == self.device.device_url - and execution.get("command_name") in COMMANDS_CLOSE + COMMANDS_CLOSE_TILT - for execution in self.coordinator.executions.values() - ): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) < cast(int, target_closure.value) + ) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover_entities/vertical_cover.py index ec502a403ad..70bc8fb1654 100644 --- a/homeassistant/components/overkiz/cover_entities/vertical_cover.py +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -19,9 +19,14 @@ from homeassistant.components.cover import ( SUPPORT_STOP, CoverDeviceClass, ) -from homeassistant.components.overkiz.coordinator import OverkizDataUpdateCoordinator -from .generic_cover import COMMANDS_STOP, OverkizGenericCover +from ..coordinator import OverkizDataUpdateCoordinator +from .generic_cover import ( + COMMANDS_CLOSE_TILT, + COMMANDS_OPEN_TILT, + COMMANDS_STOP, + OverkizGenericCover, +) COMMANDS_OPEN = [OverkizCommand.OPEN, OverkizCommand.UP, OverkizCommand.CYCLE] COMMANDS_CLOSE = [OverkizCommand.CLOSE, OverkizCommand.DOWN, OverkizCommand.CYCLE] @@ -107,6 +112,38 @@ class VerticalCover(OverkizGenericCover): if command := self.executor.select_command(*COMMANDS_CLOSE): await self.executor.async_execute_command(command) + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening or not.""" + if self.is_running(COMMANDS_OPEN + COMMANDS_OPEN_TILT): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) > cast(int, target_closure.value) + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + if self.is_running(COMMANDS_CLOSE + COMMANDS_CLOSE_TILT): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) < cast(int, target_closure.value) + class LowSpeedCover(VerticalCover): """Representation of an Overkiz Low Speed cover.""" diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index 133598070c2..9e5c38f0211 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -4,14 +4,17 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import CONF_ROON_NAME, DOMAIN from .server import RoonServer async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a roonserver from a config entry.""" hass.data.setdefault(DOMAIN, {}) - host = entry.data[CONF_HOST] + + # fallback to using host for compatibility with older configs + name = entry.data.get(CONF_ROON_NAME, entry.data[CONF_HOST]) + roonserver = RoonServer(hass, entry) if not await roonserver.async_setup(): @@ -23,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Roonlabs", - name=host, + name=f"Roon Core ({name})", ) return True diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index 31391a0ff36..6ccf97155c4 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -6,11 +6,13 @@ from roonapi import RoonApi, RoonDiscovery import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv from .const import ( AUTHENTICATE_TIMEOUT, CONF_ROON_ID, + CONF_ROON_NAME, DEFAULT_NAME, DOMAIN, ROON_APPINFO, @@ -18,7 +20,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) +DATA_SCHEMA = vol.Schema( + { + vol.Required("host"): cv.string, + vol.Required("port", default=9330): cv.port, + } +) TIMEOUT = 120 @@ -45,7 +52,7 @@ class RoonHub: _LOGGER.debug("Servers = %s", servers) return servers - async def authenticate(self, host, servers): + async def authenticate(self, host, port, servers): """Authenticate with one or more roon servers.""" def stop_apis(apis): @@ -54,6 +61,7 @@ class RoonHub: token = None core_id = None + core_name = None secs = 0 if host is None: apis = [ @@ -61,7 +69,7 @@ class RoonHub: for server in servers ] else: - apis = [RoonApi(ROON_APPINFO, None, host, blocking_init=False)] + apis = [RoonApi(ROON_APPINFO, None, host, port, blocking_init=False)] while secs <= TIMEOUT: # Roon can discover multiple devices - not all of which are proper servers, so try and authenticate with them all. @@ -71,6 +79,7 @@ class RoonHub: secs += AUTHENTICATE_TIMEOUT if auth_api: core_id = auth_api[0].core_id + core_name = auth_api[0].core_name token = auth_api[0].token break @@ -78,7 +87,7 @@ class RoonHub: await self._hass.async_add_executor_job(stop_apis, apis) - return (token, core_id) + return (token, core_id, core_name) async def discover(hass): @@ -90,15 +99,21 @@ async def discover(hass): return servers -async def authenticate(hass: core.HomeAssistant, host, servers): +async def authenticate(hass: core.HomeAssistant, host, port, servers): """Connect and authenticate home assistant.""" hub = RoonHub(hass) - (token, core_id) = await hub.authenticate(host, servers) + (token, core_id, core_name) = await hub.authenticate(host, port, servers) if token is None: raise InvalidAuth - return {CONF_HOST: host, CONF_ROON_ID: core_id, CONF_API_KEY: token} + return { + CONF_HOST: host, + CONF_PORT: port, + CONF_ROON_ID: core_id, + CONF_ROON_NAME: core_name, + CONF_API_KEY: token, + } class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -109,33 +124,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the Roon flow.""" self._host = None + self._port = None self._servers = [] async def async_step_user(self, user_input=None): - """Handle getting host details from the user.""" + """Get roon core details via discovery.""" - errors = {} self._servers = await discover(self.hass) # We discovered one or more roon - so skip to authentication if self._servers: return await self.async_step_link() + return await self.async_step_fallback() + + async def async_step_fallback(self, user_input=None): + """Get host and port details from the user.""" + errors = {} + if user_input is not None: self._host = user_input["host"] + self._port = user_input["port"] return await self.async_step_link() return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="fallback", data_schema=DATA_SCHEMA, errors=errors ) async def async_step_link(self, user_input=None): """Handle linking and authenticting with the roon server.""" - errors = {} if user_input is not None: + # Do not authenticate if the host is already configured + self._async_abort_entries_match({CONF_HOST: self._host}) + try: - info = await authenticate(self.hass, self._host, self._servers) + info = await authenticate( + self.hass, self._host, self._port, self._servers + ) + except InvalidAuth: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/roon/const.py b/homeassistant/components/roon/const.py index 7c9cd6c4999..74cf6a38160 100644 --- a/homeassistant/components/roon/const.py +++ b/homeassistant/components/roon/const.py @@ -5,6 +5,7 @@ AUTHENTICATE_TIMEOUT = 5 DOMAIN = "roon" CONF_ROON_ID = "roon_server_id" +CONF_ROON_NAME = "roon_server_name" DATA_CONFIGS = "roon_configs" diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index a3b22a3c2cc..297bf5e9d7a 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -3,7 +3,7 @@ "name": "RoonLabs music player", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roon", - "requirements": ["roonapi==0.0.38"], + "requirements": ["roonapi==0.1.1"], "codeowners": ["@pavoni"], "iot_class": "local_push", "loggers": ["roonapi"] diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index 8301bf73fdf..d5e4cded08d 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -2,15 +2,16 @@ import asyncio import logging -from roonapi import RoonApi +from roonapi import RoonApi, RoonDiscovery -from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, Platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.dt import utcnow from .const import CONF_ROON_ID, ROON_APPINFO _LOGGER = logging.getLogger(__name__) +INITIAL_SYNC_INTERVAL = 5 FULL_SYNC_INTERVAL = 30 PLATFORMS = [Platform.MEDIA_PLAYER] @@ -33,23 +34,38 @@ class RoonServer: async def async_setup(self, tries=0): """Set up a roon server based on config parameters.""" - hass = self.hass - # Host will be None for configs using discovery - host = self.config_entry.data[CONF_HOST] - token = self.config_entry.data[CONF_API_KEY] - # Default to None for compatibility with older configs - core_id = self.config_entry.data.get(CONF_ROON_ID) - _LOGGER.debug("async_setup: host=%s core_id=%s token=%s", host, core_id, token) - self.roonapi = RoonApi( - ROON_APPINFO, token, host, blocking_init=False, core_id=core_id - ) + def get_roon_host(): + host = self.config_entry.data.get(CONF_HOST) + port = self.config_entry.data.get(CONF_PORT) + if host: + _LOGGER.debug("static roon core host=%s port=%s", host, port) + return (host, port) + + discover = RoonDiscovery(core_id) + server = discover.first() + discover.stop() + _LOGGER.debug("dynamic roon core core_id=%s server=%s", core_id, server) + return (server[0], server[1]) + + def get_roon_api(): + token = self.config_entry.data[CONF_API_KEY] + (host, port) = get_roon_host() + return RoonApi(ROON_APPINFO, token, host, port, blocking_init=True) + + hass = self.hass + core_id = self.config_entry.data.get(CONF_ROON_ID) + + self.roonapi = await self.hass.async_add_executor_job(get_roon_api) + self.roonapi.register_state_callback( self.roonapi_state_callback, event_filter=["zones_changed"] ) # Default to 'host' for compatibility with older configs without core_id - self.roon_id = core_id if core_id is not None else host + self.roon_id = ( + core_id if core_id is not None else self.config_entry.data[CONF_HOST] + ) # initialize media_player platform hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) @@ -98,13 +114,14 @@ class RoonServer: async def async_do_loop(self): """Background work loop.""" self._exit = False + await asyncio.sleep(INITIAL_SYNC_INTERVAL) while not self._exit: await self.async_update_players() - # await self.async_update_playlists() await asyncio.sleep(FULL_SYNC_INTERVAL) async def async_update_changed_players(self, changed_zones_ids): """Update the players which were reported as changed by the Roon API.""" + _LOGGER.debug("async_update_changed_players %s", changed_zones_ids) for zone_id in changed_zones_ids: if zone_id not in self.roonapi.zones: # device was removed ? @@ -127,6 +144,7 @@ class RoonServer: async def async_update_players(self): """Periodic full scan of all devices.""" zone_ids = self.roonapi.zones.keys() + _LOGGER.debug("async_update_players %s", zone_ids) await self.async_update_changed_players(zone_ids) # check for any removed devices all_devs = {} diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index 565a66a1320..ce5827e2c6c 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -1,10 +1,12 @@ { "config": { "step": { - "user": { - "description": "Could not discover Roon server, please enter your the Hostname or IP.", + "user": {}, + "fallback": { + "description": "Could not discover Roon server, please enter your Hostname and Port.", "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" } }, "link": { diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 260317dca11..1aeedfb421d 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -35,8 +35,9 @@ async def async_discover_gateways_by_unique_id(hass): return discovered_gateways for host in hosts: - mac = _extract_mac_from_name(host[SL_GATEWAY_NAME]) - discovered_gateways[mac] = host + if (name := host[SL_GATEWAY_NAME]).startswith("Pentair:"): + mac = _extract_mac_from_name(name) + discovered_gateways[mac] = host _LOGGER.debug("Discovered gateways: %s", discovered_gateways) return discovered_gateways diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e120e4ada4e..d5dbb51ffc1 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.3.0", "unifi-discovery==1.1.2"], + "requirements": ["pyunifiprotect==3.4.0", "unifi-discovery==1.1.2"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c780bdcb16f..3704e9715fb 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -11,7 +11,7 @@ "zigpy-deconz==0.14.0", "zigpy==0.44.2", "zigpy-xbee==0.14.0", - "zigpy-zigate==0.8.0", + "zigpy-zigate==0.7.4", "zigpy-znp==0.7.0" ], "usb": [ diff --git a/homeassistant/const.py b/homeassistant/const.py index b307e8a5435..02e1b896df1 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 = 4 -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/generated/usb.py b/homeassistant/generated/usb.py index 1ba9b235f85..2e5104ce66d 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -6,6 +6,10 @@ To update, run python3 -m script.hassfest # fmt: off USB = [ + { + "domain": "insteon", + "vid": "10BF" + }, { "domain": "modem_callerid", "vid": "0572", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1ebfb713b70..f3df4c88c80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==2.3.0 PyNaCl==1.5.0 -aiodiscover==1.4.8 +aiodiscover==1.4.9 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index af898c00b1a..a75516a4668 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -128,7 +128,7 @@ aioazuredevops==1.3.5 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.8 +aiodiscover==1.4.9 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -162,7 +162,7 @@ aioguardian==2021.11.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.16 +aiohomekit==0.7.17 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -574,7 +574,7 @@ elgato==3.0.0 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==1.2.0 +elkm1-lib==1.2.2 # homeassistant.components.elmax elmax_api==0.0.2 @@ -1971,7 +1971,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.3.0 +pyunifiprotect==3.4.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2073,7 +2073,7 @@ rokuecp==0.16.0 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.0.38 +roonapi==0.1.1 # homeassistant.components.rova rova==0.3.0 @@ -2488,7 +2488,7 @@ zigpy-deconz==0.14.0 zigpy-xbee==0.14.0 # homeassistant.components.zha -zigpy-zigate==0.8.0 +zigpy-zigate==0.7.4 # homeassistant.components.zha zigpy-znp==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c55984b551..77df1910b77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioazuredevops==1.3.5 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.8 +aiodiscover==1.4.9 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -143,7 +143,7 @@ aioguardian==2021.11.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.16 +aiohomekit==0.7.17 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -405,7 +405,7 @@ dynalite_devices==0.1.46 elgato==3.0.0 # homeassistant.components.elkm1 -elkm1-lib==1.2.0 +elkm1-lib==1.2.2 # homeassistant.components.elmax elmax_api==0.0.2 @@ -1285,7 +1285,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.3.0 +pyunifiprotect==3.4.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1345,7 +1345,7 @@ rokuecp==0.16.0 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.0.38 +roonapi==0.1.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1610,7 +1610,7 @@ zigpy-deconz==0.14.0 zigpy-xbee==0.14.0 # homeassistant.components.zha -zigpy-zigate==0.8.0 +zigpy-zigate==0.7.4 # homeassistant.components.zha zigpy-znp==0.7.0 diff --git a/setup.cfg b/setup.cfg index b1e8c6d8d40..903d09f4267 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = homeassistant -version = 2022.4.5 +version = 2022.4.6 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 diff --git a/tests/components/daikin/test_temperature_format.py b/tests/components/daikin/test_temperature_format.py new file mode 100644 index 00000000000..d05efa98b8d --- /dev/null +++ b/tests/components/daikin/test_temperature_format.py @@ -0,0 +1,20 @@ +"""The tests for the Daikin target temperature conversion.""" +from homeassistant.components.daikin.climate import format_target_temperature + + +def test_int_conversion(): + """Check no decimal are kept when target temp is an integer.""" + formatted = format_target_temperature("16") + assert formatted == "16" + + +def test_decimal_conversion(): + """Check 1 decimal is kept when target temp is a decimal.""" + formatted = format_target_temperature("16.1") + assert formatted == "16.1" + + +def test_decimal_conversion_more_digits(): + """Check at most 1 decimal is kept when target temp is a decimal with more than 1 decimal.""" + formatted = format_target_temperature("16.09") + assert formatted == "16.1" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index aab04dae203..3caa1aa7adf 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -62,7 +62,7 @@ async def test_form(hass, fakeimg_png, mock_av_open, user_flow): TESTDATA, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "127_0_0_1_testurl_1" + assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", @@ -96,7 +96,7 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): data, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "127_0_0_1_testurl_1" + assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, @@ -176,7 +176,7 @@ async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): ) assert "errors" not in result2, f"errors={result2['errors']}" assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "127_0_0_1_testurl_1" + assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, @@ -202,22 +202,18 @@ async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): ) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) + data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" with mock_av_open as mock_setup: - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], data, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_CONTENT_TYPE: "image/jpeg"}, - ) assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "127_0_0_1_testurl_2" + assert result3["title"] == "127_0_0_1" assert result3["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, - CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", + CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, @@ -232,7 +228,7 @@ async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): "homeassistant.components.generic.camera.GenericCamera.async_camera_image", return_value=fakeimgbytes_jpg, ): - image_obj = await async_get_image(hass, "camera.127_0_0_1_testurl_2") + image_obj = await async_get_image(hass, "camera.127_0_0_1") assert image_obj.content == fakeimgbytes_jpg assert len(mock_setup.mock_calls) == 1 @@ -516,20 +512,12 @@ async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): assert result["step_id"] == "init" # try updating the config options - result2 = await hass.config_entries.options.async_configure( + result3 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, ) - # Should be shown a 2nd form - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "content_type" - - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], - user_input={CONF_CONTENT_TYPE: "image/png"}, - ) assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["data"][CONF_CONTENT_TYPE] == "image/png" + assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" # These below can be deleted after deprecation period is finished. diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index a5aca8a27d4..dda4cddc962 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -505,3 +505,77 @@ async def test_scan_calendar_error( assert await component_setup() assert not hass.states.get(TEST_ENTITY) + + +async def test_future_event_update_behavior( + hass, mock_events_list_items, component_setup +): + """Test an future event that becomes active.""" + now = dt_util.now() + now_utc = dt_util.utcnow() + one_hour_from_now = now + datetime.timedelta(minutes=60) + end_event = one_hour_from_now + datetime.timedelta(minutes=90) + event = { + **TEST_EVENT, + "start": {"dateTime": one_hour_from_now.isoformat()}, + "end": {"dateTime": end_event.isoformat()}, + } + mock_events_list_items([event]) + assert await component_setup() + + # Event has not started yet + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + + # Advance time until event has started + now += datetime.timedelta(minutes=60) + now_utc += datetime.timedelta(minutes=30) + with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( + "homeassistant.util.dt.now", return_value=now + ): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Event has started + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_ON + + +async def test_future_event_offset_update_behavior( + hass, mock_events_list_items, component_setup +): + """Test an future event that becomes active.""" + now = dt_util.now() + now_utc = dt_util.utcnow() + one_hour_from_now = now + datetime.timedelta(minutes=60) + end_event = one_hour_from_now + datetime.timedelta(minutes=90) + event_summary = "Test Event in Progress" + event = { + **TEST_EVENT, + "start": {"dateTime": one_hour_from_now.isoformat()}, + "end": {"dateTime": end_event.isoformat()}, + "summary": f"{event_summary} !!-15", + } + mock_events_list_items([event]) + assert await component_setup() + + # Event has not started yet + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + assert not state.attributes["offset_reached"] + + # Advance time until event has started + now += datetime.timedelta(minutes=45) + now_utc += datetime.timedelta(minutes=45) + with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( + "homeassistant.util.dt.now", return_value=now + ): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Event has not started, but the offset was reached + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_OFF + assert state.attributes["offset_reached"] diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 9ca54ea8d8f..878b540b721 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow +from homeassistant.components import usb from homeassistant.components.insteon.config_flow import ( HUB1, HUB2, @@ -594,3 +595,56 @@ async def test_options_override_bad_data(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "input_error"} + + +async def test_discovery_via_usb(hass): + """Test usb flow.""" + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyINSTEON", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="insteon radio", + manufacturer="test", + ) + result = await hass.config_entries.flow.async_init( + "insteon", context={"source": config_entries.SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm_usb" + + with patch("homeassistant.components.insteon.config_flow.async_connect"), patch( + "homeassistant.components.insteon.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == {"device": "/dev/ttyINSTEON"} + + +async def test_discovery_via_usb_already_setup(hass): + """Test usb flow -- already setup.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyINSTEON", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="insteon radio", + manufacturer="test", + ) + result = await hass.config_entries.flow.async_init( + "insteon", context={"source": config_entries.SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/roon/test_config_flow.py b/tests/components/roon/test_config_flow.py index 686109f968e..7cc37bc73cd 100644 --- a/tests/components/roon/test_config_flow.py +++ b/tests/components/roon/test_config_flow.py @@ -1,9 +1,11 @@ """Test the roon config flow.""" from unittest.mock import patch -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.roon.const import DOMAIN +from tests.common import MockConfigEntry + class RoonApiMock: """Class to mock the Roon API for testing.""" @@ -18,8 +20,13 @@ class RoonApiMock: """Return the roon host.""" return "core_id" + @property + def core_name(self): + """Return the roon core name.""" + return "Roon Core" + def stop(self): - """Stop socket and discovery.""" + """Stop socket.""" return @@ -93,8 +100,10 @@ async def test_successful_discovery_and_auth(hass): assert result2["title"] == "Roon Labs Music Player" assert result2["data"] == { "host": None, + "port": None, "api_key": "good_token", "roon_server_id": "core_id", + "roon_server_name": "Roon Core", } @@ -119,11 +128,11 @@ async def test_unsuccessful_discovery_user_form_and_auth(hass): # Should show the form if server was not discovered assert result["type"] == "form" - assert result["step_id"] == "user" + assert result["step_id"] == "fallback" assert result["errors"] == {} await hass.config_entries.flow.async_configure( - result["flow_id"], {"host": "1.1.1.1"} + result["flow_id"], {"host": "1.1.1.1", "port": 9331} ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -134,10 +143,52 @@ async def test_unsuccessful_discovery_user_form_and_auth(hass): assert result2["data"] == { "host": "1.1.1.1", "api_key": "good_token", + "port": 9331, + "api_key": "good_token", "roon_server_id": "core_id", + "roon_server_name": "Roon Core", } +async def test_duplicate_config(hass): + """Test user adding the host via the form for host that is already configured.""" + + CONFIG = {"host": "1.1.1.1"} + + MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG).add_to_hass( + hass + ) + + with patch( + "homeassistant.components.roon.config_flow.RoonApi", + return_value=RoonApiMock(), + ), patch( + "homeassistant.components.roon.config_flow.RoonDiscovery", + return_value=RoonDiscoveryFailedMock(), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + # Should show the form if server was not discovered + assert result["type"] == "form" + assert result["step_id"] == "fallback" + assert result["errors"] == {} + + await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.1.1.1", "port": 9331} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + async def test_successful_discovery_no_auth(hass): """Test successful discover, but failed auth."""