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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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