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