mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
Merge pull request #70331 from home-assistant/rc
This commit is contained in:
commit
2df212dcd3
@ -117,6 +117,11 @@ async def async_setup_entry(
|
|||||||
async_add_entities([DaikinClimate(daikin_api)], update_before_add=True)
|
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):
|
class DaikinClimate(ClimateEntity):
|
||||||
"""Representation of a Daikin HVAC."""
|
"""Representation of a Daikin HVAC."""
|
||||||
|
|
||||||
@ -163,9 +168,9 @@ class DaikinClimate(ClimateEntity):
|
|||||||
# temperature
|
# temperature
|
||||||
elif attr == ATTR_TEMPERATURE:
|
elif attr == ATTR_TEMPERATURE:
|
||||||
try:
|
try:
|
||||||
values[HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]] = str(
|
values[
|
||||||
round(float(value), 1)
|
HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]
|
||||||
)
|
] = format_target_temperature(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.error("Invalid temperature %s", value)
|
_LOGGER.error("Invalid temperature %s", value)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "dhcp",
|
"domain": "dhcp",
|
||||||
"name": "DHCP Discovery",
|
"name": "DHCP Discovery",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dhcp",
|
"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"],
|
"codeowners": ["@bdraco"],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
|
@ -351,21 +351,11 @@ async def async_wait_for_elk_to_sync(
|
|||||||
login_event.set()
|
login_event.set()
|
||||||
sync_event.set()
|
sync_event.set()
|
||||||
|
|
||||||
def first_response(*args, **kwargs):
|
|
||||||
_LOGGER.debug("ElkM1 received first response (VN)")
|
|
||||||
login_event.set()
|
|
||||||
|
|
||||||
def sync_complete():
|
def sync_complete():
|
||||||
sync_event.set()
|
sync_event.set()
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
elk.add_handler("login", login_status)
|
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)
|
elk.add_handler("sync_complete", sync_complete)
|
||||||
for name, event, timeout in (
|
for name, event, timeout in (
|
||||||
("login", login_event, login_timeout),
|
("login", login_event, login_timeout),
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "elkm1",
|
"domain": "elkm1",
|
||||||
"name": "Elk-M1 Control",
|
"name": "Elk-M1 Control",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
"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*" }],
|
"dhcp": [{ "registered_devices": true }, { "macaddress": "00409D*" }],
|
||||||
"codeowners": ["@gwww", "@bdraco"],
|
"codeowners": ["@gwww", "@bdraco"],
|
||||||
"dependencies": ["network"],
|
"dependencies": ["network"],
|
||||||
|
@ -137,9 +137,10 @@ class FileSizeCoordinator(DataUpdateCoordinator):
|
|||||||
raise UpdateFailed(f"Can not retrieve file statistics {error}") from error
|
raise UpdateFailed(f"Can not retrieve file statistics {error}") from error
|
||||||
|
|
||||||
size = statinfo.st_size
|
size = statinfo.st_size
|
||||||
last_updated = datetime.fromtimestamp(statinfo.st_mtime).replace(
|
last_updated = datetime.utcfromtimestamp(statinfo.st_mtime).replace(
|
||||||
tzinfo=dt_util.UTC
|
tzinfo=dt_util.UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug("size %s, last updated %s", size, last_updated)
|
_LOGGER.debug("size %s, last updated %s", size, last_updated)
|
||||||
data: dict[str, int | float | datetime] = {
|
data: dict[str, int | float | datetime] = {
|
||||||
"file": round(size / 1e6, 2),
|
"file": round(size / 1e6, 2),
|
||||||
|
@ -8,13 +8,13 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from async_timeout import timeout
|
from async_timeout import timeout
|
||||||
import av
|
import av
|
||||||
from httpx import HTTPStatusError, RequestError, TimeoutException
|
from httpx import HTTPStatusError, RequestError, TimeoutException
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
import yarl
|
||||||
|
|
||||||
from homeassistant.components.stream.const import SOURCE_TIMEOUT
|
from homeassistant.components.stream.const import SOURCE_TIMEOUT
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||||
@ -109,20 +109,6 @@ def build_schema(
|
|||||||
return vol.Schema(spec)
|
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):
|
def get_image_type(image):
|
||||||
"""Get the format of downloaded bytes that could be an image."""
|
"""Get the format of downloaded bytes that could be an image."""
|
||||||
fmt = None
|
fmt = None
|
||||||
@ -186,8 +172,7 @@ def slug_url(url) -> str | None:
|
|||||||
"""Convert a camera url into a string suitable for a camera name."""
|
"""Convert a camera url into a string suitable for a camera name."""
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
url_no_scheme = urlparse(url)._replace(scheme="")
|
return slugify(yarl.URL(url).host)
|
||||||
return slugify(urlunparse(url_no_scheme).strip("/"))
|
|
||||||
|
|
||||||
|
|
||||||
async def async_test_stream(hass, info) -> dict[str, str]:
|
async def async_test_stream(hass, info) -> dict[str, str]:
|
||||||
@ -283,17 +268,16 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if not errors:
|
if not errors:
|
||||||
user_input[CONF_CONTENT_TYPE] = still_format
|
user_input[CONF_CONTENT_TYPE] = still_format
|
||||||
user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
|
user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
|
||||||
if user_input.get(CONF_STILL_IMAGE_URL):
|
if still_url is None:
|
||||||
await self.async_set_unique_id(self.flow_id)
|
# If user didn't specify a still image URL,
|
||||||
return self.async_create_entry(
|
# The automatically generated still image that stream generates
|
||||||
title=name, data={}, options=user_input
|
# is always jpeg
|
||||||
)
|
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
|
||||||
# If user didn't specify a still image URL,
|
|
||||||
# we can't (yet) autodetect it from the stream.
|
await self.async_set_unique_id(self.flow_id)
|
||||||
# Show a conditional 2nd page to ask them the content type.
|
return self.async_create_entry(
|
||||||
self.cached_user_input = user_input
|
title=name, data={}, options=user_input
|
||||||
self.cached_title = name
|
)
|
||||||
return await self.async_step_content_type()
|
|
||||||
else:
|
else:
|
||||||
user_input = DEFAULT_DATA.copy()
|
user_input = DEFAULT_DATA.copy()
|
||||||
|
|
||||||
@ -303,22 +287,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
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:
|
async def async_step_import(self, import_config) -> FlowResult:
|
||||||
"""Handle config import from yaml."""
|
"""Handle config import from yaml."""
|
||||||
# abort if we've already got this one.
|
# abort if we've already got this one.
|
||||||
@ -362,6 +330,11 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
||||||
if not errors:
|
if not errors:
|
||||||
title = slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME
|
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 = {
|
data = {
|
||||||
CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION),
|
CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION),
|
||||||
CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE),
|
CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE),
|
||||||
@ -376,30 +349,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
CONF_FRAMERATE: user_input[CONF_FRAMERATE],
|
CONF_FRAMERATE: user_input[CONF_FRAMERATE],
|
||||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||||
}
|
}
|
||||||
if still_url:
|
return self.async_create_entry(
|
||||||
return self.async_create_entry(
|
title=title,
|
||||||
title=title,
|
data=data,
|
||||||
data=data,
|
)
|
||||||
)
|
|
||||||
self.cached_title = title
|
|
||||||
self.cached_user_input = data
|
|
||||||
return await self.async_step_content_type()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=build_schema(user_input or self.config_entry.options, True),
|
data_schema=build_schema(user_input or self.config_entry.options, True),
|
||||||
errors=errors,
|
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={},
|
|
||||||
)
|
|
||||||
|
@ -120,13 +120,20 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||||||
self._event: dict[str, Any] | None = None
|
self._event: dict[str, Any] | None = None
|
||||||
self._name: str = data[CONF_NAME]
|
self._name: str = data[CONF_NAME]
|
||||||
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
|
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
|
||||||
self._offset_reached = False
|
self._offset_value: timedelta | None = None
|
||||||
self.entity_id = entity_id
|
self.entity_id = entity_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, bool]:
|
def extra_state_attributes(self) -> dict[str, bool]:
|
||||||
"""Return the device state attributes."""
|
"""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
|
@property
|
||||||
def event(self) -> dict[str, Any] | None:
|
def event(self) -> dict[str, Any] | None:
|
||||||
@ -187,6 +194,4 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||||||
self._event.get("summary", ""), self._offset
|
self._event.get("summary", ""), self._offset
|
||||||
)
|
)
|
||||||
self._event["summary"] = summary
|
self._event["summary"] = summary
|
||||||
self._offset_reached = is_offset_reached(
|
self._offset_value = offset
|
||||||
get_date(self._event["start"]), offset
|
|
||||||
)
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "HomeKit Controller",
|
"name": "HomeKit Controller",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||||
"requirements": ["aiohomekit==0.7.16"],
|
"requirements": ["aiohomekit==0.7.17"],
|
||||||
"zeroconf": ["_hap._tcp.local."],
|
"zeroconf": ["_hap._tcp.local."],
|
||||||
"after_dependencies": ["zeroconf"],
|
"after_dependencies": ["zeroconf"],
|
||||||
"codeowners": ["@Jc2k", "@bdraco"],
|
"codeowners": ["@Jc2k", "@bdraco"],
|
||||||
|
@ -46,10 +46,12 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Estimated time it takes to complete a transition
|
# Estimated time it takes to complete a transition
|
||||||
# from one state to another
|
# from one state to another
|
||||||
TRANSITION_COMPLETE_DURATION = 30
|
TRANSITION_COMPLETE_DURATION = 40
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
RESYNC_DELAY = 60
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
@ -103,6 +105,9 @@ def hass_position_to_hd(hass_position):
|
|||||||
class PowerViewShade(ShadeEntity, CoverEntity):
|
class PowerViewShade(ShadeEntity, CoverEntity):
|
||||||
"""Representation of a powerview shade."""
|
"""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):
|
def __init__(self, coordinator, device_info, room_name, shade, name):
|
||||||
"""Initialize the shade."""
|
"""Initialize the shade."""
|
||||||
super().__init__(coordinator, device_info, room_name, shade, name)
|
super().__init__(coordinator, device_info, room_name, shade, name)
|
||||||
@ -112,6 +117,7 @@ class PowerViewShade(ShadeEntity, CoverEntity):
|
|||||||
self._last_action_timestamp = 0
|
self._last_action_timestamp = 0
|
||||||
self._scheduled_transition_update = None
|
self._scheduled_transition_update = None
|
||||||
self._current_cover_position = MIN_POSITION
|
self._current_cover_position = MIN_POSITION
|
||||||
|
self._forced_resync = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
@ -224,10 +230,12 @@ class PowerViewShade(ShadeEntity, CoverEntity):
|
|||||||
@callback
|
@callback
|
||||||
def _async_cancel_scheduled_transition_update(self):
|
def _async_cancel_scheduled_transition_update(self):
|
||||||
"""Cancel any previous updates."""
|
"""Cancel any previous updates."""
|
||||||
if not self._scheduled_transition_update:
|
if self._scheduled_transition_update:
|
||||||
return
|
self._scheduled_transition_update()
|
||||||
self._scheduled_transition_update()
|
self._scheduled_transition_update = None
|
||||||
self._scheduled_transition_update = None
|
if self._forced_resync:
|
||||||
|
self._forced_resync()
|
||||||
|
self._forced_resync = None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_schedule_update_for_transition(self, steps):
|
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)
|
_LOGGER.debug("Processing scheduled update for %s", self.name)
|
||||||
self._scheduled_transition_update = None
|
self._scheduled_transition_update = None
|
||||||
await self._async_force_refresh_state()
|
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):
|
async def _async_force_refresh_state(self):
|
||||||
"""Refresh the cover state and force the device cache to be bypassed."""
|
"""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)
|
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
|
@callback
|
||||||
def _async_update_shade_from_group(self):
|
def _async_update_shade_from_group(self):
|
||||||
"""Update with new data from the coordinator."""
|
"""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
|
# If a transition in in progress
|
||||||
# the data will be wrong
|
# the data will be wrong
|
||||||
return
|
return
|
||||||
|
@ -1,19 +1,24 @@
|
|||||||
"""Test config flow for Insteon."""
|
"""Test config flow for Insteon."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyinsteon import async_connect
|
from pyinsteon import async_connect
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components import usb
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ADDRESS,
|
CONF_ADDRESS,
|
||||||
CONF_DEVICE,
|
CONF_DEVICE,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
|
CONF_NAME,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -107,6 +112,9 @@ def _remove_x10(device, options):
|
|||||||
class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Insteon config flow handler."""
|
"""Insteon config flow handler."""
|
||||||
|
|
||||||
|
_device_path: str | None = None
|
||||||
|
_device_name: str | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry):
|
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_abort(reason="cannot_connect")
|
||||||
return self.async_create_entry(title="", data=import_info)
|
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):
|
class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
"""Handle an Insteon options flow."""
|
"""Handle an Insteon options flow."""
|
||||||
|
@ -6,5 +6,11 @@
|
|||||||
"codeowners": ["@teharris1"],
|
"codeowners": ["@teharris1"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pyinsteon", "pypubsub"]
|
"loggers": ["pyinsteon", "pypubsub"],
|
||||||
|
"after_dependencies": ["usb"],
|
||||||
|
"usb": [
|
||||||
|
{
|
||||||
|
"vid": "10BF"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
|
"flow_title": "{name}",
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "Select the Insteon modem type.",
|
"description": "Select the Insteon modem type.",
|
||||||
@ -31,6 +32,9 @@
|
|||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]"
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"confirm_usb": {
|
||||||
|
"description": "Do you want to setup {name}?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
@ -8,7 +8,11 @@
|
|||||||
"cannot_connect": "Failed to connect",
|
"cannot_connect": "Failed to connect",
|
||||||
"select_single": "Select one option."
|
"select_single": "Select one option."
|
||||||
},
|
},
|
||||||
|
"flow_title": "{name}",
|
||||||
"step": {
|
"step": {
|
||||||
|
"confirm_usb": {
|
||||||
|
"description": "Do you want to setup {name}?"
|
||||||
|
},
|
||||||
"hubv1": {
|
"hubv1": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "IP Address",
|
"host": "IP Address",
|
||||||
|
@ -12,7 +12,7 @@ from pylutron_caseta.smartbridge import Smartbridge
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
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.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
@ -41,6 +41,7 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
LUTRON_CASETA_BUTTON_EVENT,
|
LUTRON_CASETA_BUTTON_EVENT,
|
||||||
MANUFACTURER,
|
MANUFACTURER,
|
||||||
|
UNASSIGNED_AREA,
|
||||||
)
|
)
|
||||||
from .device_trigger import (
|
from .device_trigger import (
|
||||||
DEVICE_TYPE_SUBTYPE_MAP_TO_LIP,
|
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:
|
if "serial" not in device or device["serial"] in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(device["serial"])
|
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(
|
dr_device = device_registry.async_get_or_create(**device_args)
|
||||||
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"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
button_devices_by_dr_id[dr_device.id] = device
|
button_devices_by_dr_id[dr_device.id] = device
|
||||||
|
|
||||||
return button_devices_by_dr_id
|
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
|
@callback
|
||||||
def _async_subscribe_pico_remote_events(
|
def _async_subscribe_pico_remote_events(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -230,7 +240,7 @@ def _async_subscribe_pico_remote_events(
|
|||||||
action = ACTION_RELEASE
|
action = ACTION_RELEASE
|
||||||
|
|
||||||
type_ = device["type"]
|
type_ = device["type"]
|
||||||
area, name = device["name"].split("_", 1)
|
area, name = _area_and_name_from_name(device["name"])
|
||||||
button_number = device["button_number"]
|
button_number = device["button_number"]
|
||||||
# The original implementation used LIP instead of LEAP
|
# The original implementation used LIP instead of LEAP
|
||||||
# so we need to convert the button number to maintain compat
|
# so we need to convert the button number to maintain compat
|
||||||
@ -322,15 +332,18 @@ class LutronCasetaDevice(Entity):
|
|||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Return the device info."""
|
"""Return the device info."""
|
||||||
return DeviceInfo(
|
device = self._device
|
||||||
|
info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.serial)},
|
identifiers={(DOMAIN, self.serial)},
|
||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
model=f"{self._device['model']} ({self._device['type']})",
|
model=f"{device['model']} ({device['type']})",
|
||||||
name=self.name,
|
name=self.name,
|
||||||
suggested_area=self._device["name"].split("_")[0],
|
|
||||||
via_device=(DOMAIN, self._bridge_device["serial"]),
|
via_device=(DOMAIN, self._bridge_device["serial"]),
|
||||||
configuration_url="https://device-login.lutron.com",
|
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
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
|
@ -33,3 +33,5 @@ ACTION_RELEASE = "release"
|
|||||||
CONF_SUBTYPE = "subtype"
|
CONF_SUBTYPE = "subtype"
|
||||||
|
|
||||||
BRIDGE_TIMEOUT = 35
|
BRIDGE_TIMEOUT = 35
|
||||||
|
|
||||||
|
UNASSIGNED_AREA = "Unassigned"
|
||||||
|
@ -14,7 +14,12 @@ from homeassistant.components.cover import (
|
|||||||
CoverDeviceClass,
|
CoverDeviceClass,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .generic_cover import COMMANDS_STOP, OverkizGenericCover
|
from .generic_cover import (
|
||||||
|
COMMANDS_CLOSE,
|
||||||
|
COMMANDS_OPEN,
|
||||||
|
COMMANDS_STOP,
|
||||||
|
OverkizGenericCover,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Awning(OverkizGenericCover):
|
class Awning(OverkizGenericCover):
|
||||||
@ -67,3 +72,35 @@ class Awning(OverkizGenericCover):
|
|||||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close the cover."""
|
"""Close the cover."""
|
||||||
await self.executor.async_execute_command(OverkizCommand.UNDEPLOY)
|
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)
|
||||||
|
@ -14,7 +14,8 @@ from homeassistant.components.cover import (
|
|||||||
SUPPORT_STOP_TILT,
|
SUPPORT_STOP_TILT,
|
||||||
CoverEntity,
|
CoverEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.components.overkiz.entity import OverkizEntity
|
|
||||||
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
ATTR_OBSTRUCTION_DETECTED = "obstruction-detected"
|
ATTR_OBSTRUCTION_DETECTED = "obstruction-detected"
|
||||||
|
|
||||||
@ -111,55 +112,13 @@ class OverkizGenericCover(OverkizEntity, CoverEntity):
|
|||||||
if command := self.executor.select_command(*COMMANDS_STOP_TILT):
|
if command := self.executor.select_command(*COMMANDS_STOP_TILT):
|
||||||
await self.executor.async_execute_command(command)
|
await self.executor.async_execute_command(command)
|
||||||
|
|
||||||
@property
|
def is_running(self, commands: list[OverkizCommand]) -> bool:
|
||||||
def is_opening(self) -> bool | None:
|
"""Return if the given commands are currently running."""
|
||||||
"""Return if the cover is opening or not."""
|
return any(
|
||||||
|
|
||||||
if self.assumed_state:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if cover movement execution is currently running
|
|
||||||
if any(
|
|
||||||
execution.get("device_url") == self.device.device_url
|
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()
|
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
|
@property
|
||||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||||
|
@ -19,9 +19,14 @@ from homeassistant.components.cover import (
|
|||||||
SUPPORT_STOP,
|
SUPPORT_STOP,
|
||||||
CoverDeviceClass,
|
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_OPEN = [OverkizCommand.OPEN, OverkizCommand.UP, OverkizCommand.CYCLE]
|
||||||
COMMANDS_CLOSE = [OverkizCommand.CLOSE, OverkizCommand.DOWN, 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):
|
if command := self.executor.select_command(*COMMANDS_CLOSE):
|
||||||
await self.executor.async_execute_command(command)
|
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):
|
class LowSpeedCover(VerticalCover):
|
||||||
"""Representation of an Overkiz Low Speed cover."""
|
"""Representation of an Overkiz Low Speed cover."""
|
||||||
|
@ -4,14 +4,17 @@ from homeassistant.const import CONF_HOST
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_ROON_NAME, DOMAIN
|
||||||
from .server import RoonServer
|
from .server import RoonServer
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up a roonserver from a config entry."""
|
"""Set up a roonserver from a config entry."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
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)
|
roonserver = RoonServer(hass, entry)
|
||||||
|
|
||||||
if not await roonserver.async_setup():
|
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,
|
config_entry_id=entry.entry_id,
|
||||||
identifiers={(DOMAIN, entry.entry_id)},
|
identifiers={(DOMAIN, entry.entry_id)},
|
||||||
manufacturer="Roonlabs",
|
manufacturer="Roonlabs",
|
||||||
name=host,
|
name=f"Roon Core ({name})",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -6,11 +6,13 @@ from roonapi import RoonApi, RoonDiscovery
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries, core, exceptions
|
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 (
|
from .const import (
|
||||||
AUTHENTICATE_TIMEOUT,
|
AUTHENTICATE_TIMEOUT,
|
||||||
CONF_ROON_ID,
|
CONF_ROON_ID,
|
||||||
|
CONF_ROON_NAME,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ROON_APPINFO,
|
ROON_APPINFO,
|
||||||
@ -18,7 +20,12 @@ from .const import (
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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
|
TIMEOUT = 120
|
||||||
|
|
||||||
@ -45,7 +52,7 @@ class RoonHub:
|
|||||||
_LOGGER.debug("Servers = %s", servers)
|
_LOGGER.debug("Servers = %s", servers)
|
||||||
return servers
|
return servers
|
||||||
|
|
||||||
async def authenticate(self, host, servers):
|
async def authenticate(self, host, port, servers):
|
||||||
"""Authenticate with one or more roon servers."""
|
"""Authenticate with one or more roon servers."""
|
||||||
|
|
||||||
def stop_apis(apis):
|
def stop_apis(apis):
|
||||||
@ -54,6 +61,7 @@ class RoonHub:
|
|||||||
|
|
||||||
token = None
|
token = None
|
||||||
core_id = None
|
core_id = None
|
||||||
|
core_name = None
|
||||||
secs = 0
|
secs = 0
|
||||||
if host is None:
|
if host is None:
|
||||||
apis = [
|
apis = [
|
||||||
@ -61,7 +69,7 @@ class RoonHub:
|
|||||||
for server in servers
|
for server in servers
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
apis = [RoonApi(ROON_APPINFO, None, host, blocking_init=False)]
|
apis = [RoonApi(ROON_APPINFO, None, host, port, blocking_init=False)]
|
||||||
|
|
||||||
while secs <= TIMEOUT:
|
while secs <= TIMEOUT:
|
||||||
# Roon can discover multiple devices - not all of which are proper servers, so try and authenticate with them all.
|
# 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
|
secs += AUTHENTICATE_TIMEOUT
|
||||||
if auth_api:
|
if auth_api:
|
||||||
core_id = auth_api[0].core_id
|
core_id = auth_api[0].core_id
|
||||||
|
core_name = auth_api[0].core_name
|
||||||
token = auth_api[0].token
|
token = auth_api[0].token
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -78,7 +87,7 @@ class RoonHub:
|
|||||||
|
|
||||||
await self._hass.async_add_executor_job(stop_apis, apis)
|
await self._hass.async_add_executor_job(stop_apis, apis)
|
||||||
|
|
||||||
return (token, core_id)
|
return (token, core_id, core_name)
|
||||||
|
|
||||||
|
|
||||||
async def discover(hass):
|
async def discover(hass):
|
||||||
@ -90,15 +99,21 @@ async def discover(hass):
|
|||||||
return servers
|
return servers
|
||||||
|
|
||||||
|
|
||||||
async def authenticate(hass: core.HomeAssistant, host, servers):
|
async def authenticate(hass: core.HomeAssistant, host, port, servers):
|
||||||
"""Connect and authenticate home assistant."""
|
"""Connect and authenticate home assistant."""
|
||||||
|
|
||||||
hub = RoonHub(hass)
|
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:
|
if token is None:
|
||||||
raise InvalidAuth
|
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):
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
@ -109,33 +124,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the Roon flow."""
|
"""Initialize the Roon flow."""
|
||||||
self._host = None
|
self._host = None
|
||||||
|
self._port = None
|
||||||
self._servers = []
|
self._servers = []
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
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)
|
self._servers = await discover(self.hass)
|
||||||
|
|
||||||
# We discovered one or more roon - so skip to authentication
|
# We discovered one or more roon - so skip to authentication
|
||||||
if self._servers:
|
if self._servers:
|
||||||
return await self.async_step_link()
|
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:
|
if user_input is not None:
|
||||||
self._host = user_input["host"]
|
self._host = user_input["host"]
|
||||||
|
self._port = user_input["port"]
|
||||||
return await self.async_step_link()
|
return await self.async_step_link()
|
||||||
|
|
||||||
return self.async_show_form(
|
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):
|
async def async_step_link(self, user_input=None):
|
||||||
"""Handle linking and authenticting with the roon server."""
|
"""Handle linking and authenticting with the roon server."""
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
if user_input is not None:
|
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:
|
try:
|
||||||
info = await authenticate(self.hass, self._host, self._servers)
|
info = await authenticate(
|
||||||
|
self.hass, self._host, self._port, self._servers
|
||||||
|
)
|
||||||
|
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
|
@ -5,6 +5,7 @@ AUTHENTICATE_TIMEOUT = 5
|
|||||||
DOMAIN = "roon"
|
DOMAIN = "roon"
|
||||||
|
|
||||||
CONF_ROON_ID = "roon_server_id"
|
CONF_ROON_ID = "roon_server_id"
|
||||||
|
CONF_ROON_NAME = "roon_server_name"
|
||||||
|
|
||||||
DATA_CONFIGS = "roon_configs"
|
DATA_CONFIGS = "roon_configs"
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "RoonLabs music player",
|
"name": "RoonLabs music player",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/roon",
|
"documentation": "https://www.home-assistant.io/integrations/roon",
|
||||||
"requirements": ["roonapi==0.0.38"],
|
"requirements": ["roonapi==0.1.1"],
|
||||||
"codeowners": ["@pavoni"],
|
"codeowners": ["@pavoni"],
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["roonapi"]
|
"loggers": ["roonapi"]
|
||||||
|
@ -2,15 +2,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
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.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from .const import CONF_ROON_ID, ROON_APPINFO
|
from .const import CONF_ROON_ID, ROON_APPINFO
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
INITIAL_SYNC_INTERVAL = 5
|
||||||
FULL_SYNC_INTERVAL = 30
|
FULL_SYNC_INTERVAL = 30
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||||
|
|
||||||
@ -33,23 +34,38 @@ class RoonServer:
|
|||||||
|
|
||||||
async def async_setup(self, tries=0):
|
async def async_setup(self, tries=0):
|
||||||
"""Set up a roon server based on config parameters."""
|
"""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(
|
def get_roon_host():
|
||||||
ROON_APPINFO, token, host, blocking_init=False, core_id=core_id
|
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.register_state_callback(
|
||||||
self.roonapi_state_callback, event_filter=["zones_changed"]
|
self.roonapi_state_callback, event_filter=["zones_changed"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default to 'host' for compatibility with older configs without core_id
|
# 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
|
# initialize media_player platform
|
||||||
hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS)
|
hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS)
|
||||||
@ -98,13 +114,14 @@ class RoonServer:
|
|||||||
async def async_do_loop(self):
|
async def async_do_loop(self):
|
||||||
"""Background work loop."""
|
"""Background work loop."""
|
||||||
self._exit = False
|
self._exit = False
|
||||||
|
await asyncio.sleep(INITIAL_SYNC_INTERVAL)
|
||||||
while not self._exit:
|
while not self._exit:
|
||||||
await self.async_update_players()
|
await self.async_update_players()
|
||||||
# await self.async_update_playlists()
|
|
||||||
await asyncio.sleep(FULL_SYNC_INTERVAL)
|
await asyncio.sleep(FULL_SYNC_INTERVAL)
|
||||||
|
|
||||||
async def async_update_changed_players(self, changed_zones_ids):
|
async def async_update_changed_players(self, changed_zones_ids):
|
||||||
"""Update the players which were reported as changed by the Roon API."""
|
"""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:
|
for zone_id in changed_zones_ids:
|
||||||
if zone_id not in self.roonapi.zones:
|
if zone_id not in self.roonapi.zones:
|
||||||
# device was removed ?
|
# device was removed ?
|
||||||
@ -127,6 +144,7 @@ class RoonServer:
|
|||||||
async def async_update_players(self):
|
async def async_update_players(self):
|
||||||
"""Periodic full scan of all devices."""
|
"""Periodic full scan of all devices."""
|
||||||
zone_ids = self.roonapi.zones.keys()
|
zone_ids = self.roonapi.zones.keys()
|
||||||
|
_LOGGER.debug("async_update_players %s", zone_ids)
|
||||||
await self.async_update_changed_players(zone_ids)
|
await self.async_update_changed_players(zone_ids)
|
||||||
# check for any removed devices
|
# check for any removed devices
|
||||||
all_devs = {}
|
all_devs = {}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {},
|
||||||
"description": "Could not discover Roon server, please enter your the Hostname or IP.",
|
"fallback": {
|
||||||
|
"description": "Could not discover Roon server, please enter your Hostname and Port.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
|
@ -35,8 +35,9 @@ async def async_discover_gateways_by_unique_id(hass):
|
|||||||
return discovered_gateways
|
return discovered_gateways
|
||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
mac = _extract_mac_from_name(host[SL_GATEWAY_NAME])
|
if (name := host[SL_GATEWAY_NAME]).startswith("Pentair:"):
|
||||||
discovered_gateways[mac] = host
|
mac = _extract_mac_from_name(name)
|
||||||
|
discovered_gateways[mac] = host
|
||||||
|
|
||||||
_LOGGER.debug("Discovered gateways: %s", discovered_gateways)
|
_LOGGER.debug("Discovered gateways: %s", discovered_gateways)
|
||||||
return discovered_gateways
|
return discovered_gateways
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "UniFi Protect",
|
"name": "UniFi Protect",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/unifiprotect",
|
"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"],
|
"dependencies": ["http"],
|
||||||
"codeowners": ["@briis", "@AngellusMortis", "@bdraco"],
|
"codeowners": ["@briis", "@AngellusMortis", "@bdraco"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
"zigpy-deconz==0.14.0",
|
"zigpy-deconz==0.14.0",
|
||||||
"zigpy==0.44.2",
|
"zigpy==0.44.2",
|
||||||
"zigpy-xbee==0.14.0",
|
"zigpy-xbee==0.14.0",
|
||||||
"zigpy-zigate==0.8.0",
|
"zigpy-zigate==0.7.4",
|
||||||
"zigpy-znp==0.7.0"
|
"zigpy-znp==0.7.0"
|
||||||
],
|
],
|
||||||
"usb": [
|
"usb": [
|
||||||
|
@ -7,7 +7,7 @@ from .backports.enum import StrEnum
|
|||||||
|
|
||||||
MAJOR_VERSION: Final = 2022
|
MAJOR_VERSION: Final = 2022
|
||||||
MINOR_VERSION: Final = 4
|
MINOR_VERSION: Final = 4
|
||||||
PATCH_VERSION: Final = "5"
|
PATCH_VERSION: Final = "6"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
|
||||||
|
@ -6,6 +6,10 @@ To update, run python3 -m script.hassfest
|
|||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
USB = [
|
USB = [
|
||||||
|
{
|
||||||
|
"domain": "insteon",
|
||||||
|
"vid": "10BF"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "modem_callerid",
|
"domain": "modem_callerid",
|
||||||
"vid": "0572",
|
"vid": "0572",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
PyJWT==2.3.0
|
PyJWT==2.3.0
|
||||||
PyNaCl==1.5.0
|
PyNaCl==1.5.0
|
||||||
aiodiscover==1.4.8
|
aiodiscover==1.4.9
|
||||||
aiohttp==3.8.1
|
aiohttp==3.8.1
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
astral==2.2
|
astral==2.2
|
||||||
|
@ -128,7 +128,7 @@ aioazuredevops==1.3.5
|
|||||||
aiobotocore==2.1.0
|
aiobotocore==2.1.0
|
||||||
|
|
||||||
# homeassistant.components.dhcp
|
# homeassistant.components.dhcp
|
||||||
aiodiscover==1.4.8
|
aiodiscover==1.4.9
|
||||||
|
|
||||||
# homeassistant.components.dnsip
|
# homeassistant.components.dnsip
|
||||||
# homeassistant.components.minecraft_server
|
# homeassistant.components.minecraft_server
|
||||||
@ -162,7 +162,7 @@ aioguardian==2021.11.0
|
|||||||
aioharmony==0.2.9
|
aioharmony==0.2.9
|
||||||
|
|
||||||
# homeassistant.components.homekit_controller
|
# homeassistant.components.homekit_controller
|
||||||
aiohomekit==0.7.16
|
aiohomekit==0.7.17
|
||||||
|
|
||||||
# homeassistant.components.emulated_hue
|
# homeassistant.components.emulated_hue
|
||||||
# homeassistant.components.http
|
# homeassistant.components.http
|
||||||
@ -574,7 +574,7 @@ elgato==3.0.0
|
|||||||
eliqonline==1.2.2
|
eliqonline==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.elkm1
|
# homeassistant.components.elkm1
|
||||||
elkm1-lib==1.2.0
|
elkm1-lib==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.elmax
|
# homeassistant.components.elmax
|
||||||
elmax_api==0.0.2
|
elmax_api==0.0.2
|
||||||
@ -1971,7 +1971,7 @@ pytrafikverket==0.1.6.2
|
|||||||
pyudev==0.22.0
|
pyudev==0.22.0
|
||||||
|
|
||||||
# homeassistant.components.unifiprotect
|
# homeassistant.components.unifiprotect
|
||||||
pyunifiprotect==3.3.0
|
pyunifiprotect==3.4.0
|
||||||
|
|
||||||
# homeassistant.components.uptimerobot
|
# homeassistant.components.uptimerobot
|
||||||
pyuptimerobot==22.2.0
|
pyuptimerobot==22.2.0
|
||||||
@ -2073,7 +2073,7 @@ rokuecp==0.16.0
|
|||||||
roombapy==1.6.5
|
roombapy==1.6.5
|
||||||
|
|
||||||
# homeassistant.components.roon
|
# homeassistant.components.roon
|
||||||
roonapi==0.0.38
|
roonapi==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.rova
|
# homeassistant.components.rova
|
||||||
rova==0.3.0
|
rova==0.3.0
|
||||||
@ -2488,7 +2488,7 @@ zigpy-deconz==0.14.0
|
|||||||
zigpy-xbee==0.14.0
|
zigpy-xbee==0.14.0
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-zigate==0.8.0
|
zigpy-zigate==0.7.4
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-znp==0.7.0
|
zigpy-znp==0.7.0
|
||||||
|
@ -112,7 +112,7 @@ aioazuredevops==1.3.5
|
|||||||
aiobotocore==2.1.0
|
aiobotocore==2.1.0
|
||||||
|
|
||||||
# homeassistant.components.dhcp
|
# homeassistant.components.dhcp
|
||||||
aiodiscover==1.4.8
|
aiodiscover==1.4.9
|
||||||
|
|
||||||
# homeassistant.components.dnsip
|
# homeassistant.components.dnsip
|
||||||
# homeassistant.components.minecraft_server
|
# homeassistant.components.minecraft_server
|
||||||
@ -143,7 +143,7 @@ aioguardian==2021.11.0
|
|||||||
aioharmony==0.2.9
|
aioharmony==0.2.9
|
||||||
|
|
||||||
# homeassistant.components.homekit_controller
|
# homeassistant.components.homekit_controller
|
||||||
aiohomekit==0.7.16
|
aiohomekit==0.7.17
|
||||||
|
|
||||||
# homeassistant.components.emulated_hue
|
# homeassistant.components.emulated_hue
|
||||||
# homeassistant.components.http
|
# homeassistant.components.http
|
||||||
@ -405,7 +405,7 @@ dynalite_devices==0.1.46
|
|||||||
elgato==3.0.0
|
elgato==3.0.0
|
||||||
|
|
||||||
# homeassistant.components.elkm1
|
# homeassistant.components.elkm1
|
||||||
elkm1-lib==1.2.0
|
elkm1-lib==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.elmax
|
# homeassistant.components.elmax
|
||||||
elmax_api==0.0.2
|
elmax_api==0.0.2
|
||||||
@ -1285,7 +1285,7 @@ pytrafikverket==0.1.6.2
|
|||||||
pyudev==0.22.0
|
pyudev==0.22.0
|
||||||
|
|
||||||
# homeassistant.components.unifiprotect
|
# homeassistant.components.unifiprotect
|
||||||
pyunifiprotect==3.3.0
|
pyunifiprotect==3.4.0
|
||||||
|
|
||||||
# homeassistant.components.uptimerobot
|
# homeassistant.components.uptimerobot
|
||||||
pyuptimerobot==22.2.0
|
pyuptimerobot==22.2.0
|
||||||
@ -1345,7 +1345,7 @@ rokuecp==0.16.0
|
|||||||
roombapy==1.6.5
|
roombapy==1.6.5
|
||||||
|
|
||||||
# homeassistant.components.roon
|
# homeassistant.components.roon
|
||||||
roonapi==0.0.38
|
roonapi==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.rpi_power
|
# homeassistant.components.rpi_power
|
||||||
rpi-bad-power==0.1.0
|
rpi-bad-power==0.1.0
|
||||||
@ -1610,7 +1610,7 @@ zigpy-deconz==0.14.0
|
|||||||
zigpy-xbee==0.14.0
|
zigpy-xbee==0.14.0
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-zigate==0.8.0
|
zigpy-zigate==0.7.4
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-znp==0.7.0
|
zigpy-znp==0.7.0
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
name = homeassistant
|
name = homeassistant
|
||||||
version = 2022.4.5
|
version = 2022.4.6
|
||||||
author = The Home Assistant Authors
|
author = The Home Assistant Authors
|
||||||
author_email = hello@home-assistant.io
|
author_email = hello@home-assistant.io
|
||||||
license = Apache-2.0
|
license = Apache-2.0
|
||||||
|
20
tests/components/daikin/test_temperature_format.py
Normal file
20
tests/components/daikin/test_temperature_format.py
Normal 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"
|
@ -62,7 +62,7 @@ async def test_form(hass, fakeimg_png, mock_av_open, user_flow):
|
|||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
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"] == {
|
assert result2["options"] == {
|
||||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
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,
|
data,
|
||||||
)
|
)
|
||||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
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"] == {
|
assert result2["options"] == {
|
||||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
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 "errors" not in result2, f"errors={result2['errors']}"
|
||||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
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"] == {
|
assert result2["options"] == {
|
||||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
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 = TESTDATA.copy()
|
||||||
data.pop(CONF_STILL_IMAGE_URL)
|
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:
|
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"],
|
result["flow_id"],
|
||||||
data,
|
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["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"] == {
|
assert result3["options"] == {
|
||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
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_USERNAME: "fred_flintstone",
|
||||||
CONF_PASSWORD: "bambam",
|
CONF_PASSWORD: "bambam",
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
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",
|
"homeassistant.components.generic.camera.GenericCamera.async_camera_image",
|
||||||
return_value=fakeimgbytes_jpg,
|
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 image_obj.content == fakeimgbytes_jpg
|
||||||
assert len(mock_setup.mock_calls) == 1
|
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"
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
# try updating the config options
|
# try updating the config options
|
||||||
result2 = await hass.config_entries.options.async_configure(
|
result3 = await hass.config_entries.options.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input=data,
|
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["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.
|
# These below can be deleted after deprecation period is finished.
|
||||||
|
@ -505,3 +505,77 @@ async def test_scan_calendar_error(
|
|||||||
assert await component_setup()
|
assert await component_setup()
|
||||||
|
|
||||||
assert not hass.states.get(TEST_ENTITY)
|
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"]
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from homeassistant import config_entries, data_entry_flow
|
from homeassistant import config_entries, data_entry_flow
|
||||||
|
from homeassistant.components import usb
|
||||||
from homeassistant.components.insteon.config_flow import (
|
from homeassistant.components.insteon.config_flow import (
|
||||||
HUB1,
|
HUB1,
|
||||||
HUB2,
|
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["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
assert result["errors"] == {"base": "input_error"}
|
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"
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""Test the roon config flow."""
|
"""Test the roon config flow."""
|
||||||
from unittest.mock import patch
|
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 homeassistant.components.roon.const import DOMAIN
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
class RoonApiMock:
|
class RoonApiMock:
|
||||||
"""Class to mock the Roon API for testing."""
|
"""Class to mock the Roon API for testing."""
|
||||||
@ -18,8 +20,13 @@ class RoonApiMock:
|
|||||||
"""Return the roon host."""
|
"""Return the roon host."""
|
||||||
return "core_id"
|
return "core_id"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def core_name(self):
|
||||||
|
"""Return the roon core name."""
|
||||||
|
return "Roon Core"
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop socket and discovery."""
|
"""Stop socket."""
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@ -93,8 +100,10 @@ async def test_successful_discovery_and_auth(hass):
|
|||||||
assert result2["title"] == "Roon Labs Music Player"
|
assert result2["title"] == "Roon Labs Music Player"
|
||||||
assert result2["data"] == {
|
assert result2["data"] == {
|
||||||
"host": None,
|
"host": None,
|
||||||
|
"port": None,
|
||||||
"api_key": "good_token",
|
"api_key": "good_token",
|
||||||
"roon_server_id": "core_id",
|
"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
|
# Should show the form if server was not discovered
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "fallback"
|
||||||
assert result["errors"] == {}
|
assert result["errors"] == {}
|
||||||
|
|
||||||
await hass.config_entries.flow.async_configure(
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input={}
|
result["flow_id"], user_input={}
|
||||||
@ -134,10 +143,52 @@ async def test_unsuccessful_discovery_user_form_and_auth(hass):
|
|||||||
assert result2["data"] == {
|
assert result2["data"] == {
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "good_token",
|
"api_key": "good_token",
|
||||||
|
"port": 9331,
|
||||||
|
"api_key": "good_token",
|
||||||
"roon_server_id": "core_id",
|
"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):
|
async def test_successful_discovery_no_auth(hass):
|
||||||
"""Test successful discover, but failed auth."""
|
"""Test successful discover, but failed auth."""
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user