Merge pull request #70331 from home-assistant/rc

This commit is contained in:
Paulus Schoutsen 2022-04-20 11:27:33 -07:00 committed by GitHub
commit 2df212dcd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 561 additions and 240 deletions

View File

@ -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)

View File

@ -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",

View File

@ -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),

View File

@ -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"],

View File

@ -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),

View File

@ -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={},
)

View File

@ -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

View File

@ -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"],

View File

@ -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

View File

@ -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."""

View File

@ -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"
}
]
}

View File

@ -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": {

View File

@ -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",

View File

@ -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):

View File

@ -33,3 +33,5 @@ ACTION_RELEASE = "release"
CONF_SUBTYPE = "subtype"
BRIDGE_TIMEOUT = 35
UNASSIGNED_AREA = "Unassigned"

View File

@ -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)

View File

@ -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:

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"]

View File

@ -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 = {}

View File

@ -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": {

View File

@ -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

View File

@ -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",

View File

@ -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": [

View File

@ -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)

View File

@ -6,6 +6,10 @@ To update, run python3 -m script.hassfest
# fmt: off
USB = [
{
"domain": "insteon",
"vid": "10BF"
},
{
"domain": "modem_callerid",
"vid": "0572",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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.

View File

@ -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"]

View File

@ -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"

View File

@ -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."""