forked from home-assistant/core
Compare commits
37 Commits
dev
...
2021.9.0b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ada6c6e2 | ||
|
|
76bb036968 | ||
|
|
d8b64be41c | ||
|
|
b3e0b7b86e | ||
|
|
e097e4c1c2 | ||
|
|
34f0fecef8 | ||
|
|
f53a10d39a | ||
|
|
5b993129d6 | ||
|
|
865656d436 | ||
|
|
fb25c6c115 | ||
|
|
c963cf8743 | ||
|
|
ddb28db21a | ||
|
|
bfc98b444f | ||
|
|
f9a0f44137 | ||
|
|
93750d71ce | ||
|
|
06e4003640 | ||
|
|
97ff5e2085 | ||
|
|
8a2c07ce19 | ||
|
|
9f7398e0df | ||
|
|
7df84dadad | ||
|
|
2a1e943b18 | ||
|
|
e6e72bfa82 | ||
|
|
219868b308 | ||
|
|
67dd861d8c | ||
|
|
f2765ba320 | ||
|
|
aefd3df914 | ||
|
|
3658eeb8d1 | ||
|
|
080cb6b6e9 | ||
|
|
20796303da | ||
|
|
dff6151ff4 | ||
|
|
6f24f4e302 | ||
|
|
175febe635 | ||
|
|
aa907f4d10 | ||
|
|
3d09478aea | ||
|
|
05df9b4b8b | ||
|
|
1865a28083 | ||
|
|
f78d57515a |
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.7.19"],
|
||||
"requirements": ["bimmer_connected==0.7.20"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -165,10 +165,7 @@ async def _async_get_image(
|
||||
width=width, height=height
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
|
||||
camera.entity_id,
|
||||
)
|
||||
camera.async_warn_old_async_camera_image_signature()
|
||||
image_bytes = await camera.async_camera_image()
|
||||
|
||||
if image_bytes:
|
||||
@@ -381,6 +378,7 @@ class Camera(Entity):
|
||||
self.stream_options: dict[str, str] = {}
|
||||
self.content_type: str = DEFAULT_CONTENT_TYPE
|
||||
self.access_tokens: collections.deque = collections.deque([], 2)
|
||||
self._warned_old_signature = False
|
||||
self.async_update_token()
|
||||
|
||||
@property
|
||||
@@ -455,11 +453,20 @@ class Camera(Entity):
|
||||
return await self.hass.async_add_executor_job(
|
||||
partial(self.camera_image, width=width, height=height)
|
||||
)
|
||||
self.async_warn_old_async_camera_image_signature()
|
||||
return await self.hass.async_add_executor_job(self.camera_image)
|
||||
|
||||
# Remove in 2022.1 after all custom components have had a chance to change their signature
|
||||
@callback
|
||||
def async_warn_old_async_camera_image_signature(self) -> None:
|
||||
"""Warn once when calling async_camera_image with the function old signature."""
|
||||
if self._warned_old_signature:
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
|
||||
self.entity_id,
|
||||
)
|
||||
return await self.hass.async_add_executor_job(self.camera_image)
|
||||
self._warned_old_signature = True
|
||||
|
||||
async def handle_async_still_stream(
|
||||
self, request: web.Request, interval: float
|
||||
|
||||
@@ -5,7 +5,12 @@ import datetime
|
||||
import logging
|
||||
from typing import Callable, TypedDict
|
||||
|
||||
from fritzconnection.core.exceptions import FritzConnectionException
|
||||
from fritzconnection.core.exceptions import (
|
||||
FritzActionError,
|
||||
FritzActionFailedError,
|
||||
FritzConnectionException,
|
||||
FritzServiceError,
|
||||
)
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -260,12 +265,16 @@ async def async_setup_entry(
|
||||
return
|
||||
|
||||
entities = []
|
||||
dslinterface = await hass.async_add_executor_job(
|
||||
fritzbox_tools.connection.call_action,
|
||||
"WANDSLInterfaceConfig:1",
|
||||
"GetInfo",
|
||||
)
|
||||
dsl: bool = dslinterface["NewEnable"]
|
||||
dsl: bool = False
|
||||
try:
|
||||
dslinterface = await hass.async_add_executor_job(
|
||||
fritzbox_tools.connection.call_action,
|
||||
"WANDSLInterfaceConfig:1",
|
||||
"GetInfo",
|
||||
)
|
||||
dsl = dslinterface["NewEnable"]
|
||||
except (FritzActionError, FritzActionFailedError, FritzServiceError):
|
||||
pass
|
||||
|
||||
for sensor_type, sensor_data in SENSOR_DATA.items():
|
||||
if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION:
|
||||
|
||||
@@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
self.is_osram = False
|
||||
self.is_philips = False
|
||||
self.is_innr = False
|
||||
self.is_livarno = False
|
||||
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
|
||||
self.gamut = None
|
||||
else:
|
||||
self.is_osram = light.manufacturername == "OSRAM"
|
||||
self.is_philips = light.manufacturername == "Philips"
|
||||
self.is_innr = light.manufacturername == "innr"
|
||||
self.is_livarno = light.manufacturername.startswith("_TZ3000_")
|
||||
self.gamut_typ = self.light.colorgamuttype
|
||||
self.gamut = self.light.colorgamut
|
||||
_LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut))
|
||||
@@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
"""Return the warmest color_temp that this light supports."""
|
||||
if self.is_group:
|
||||
return super().max_mireds
|
||||
if self.is_livarno:
|
||||
return 500
|
||||
|
||||
max_mireds = self.light.controlcapabilities.get("ct", {}).get("max")
|
||||
|
||||
@@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
elif flash == FLASH_SHORT:
|
||||
command["alert"] = "select"
|
||||
del command["on"]
|
||||
elif not self.is_innr:
|
||||
elif not self.is_innr and not self.is_livarno:
|
||||
command["alert"] = "none"
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
@@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
elif flash == FLASH_SHORT:
|
||||
command["alert"] = "select"
|
||||
del command["on"]
|
||||
elif not self.is_innr:
|
||||
elif not self.is_innr and not self.is_livarno:
|
||||
command["alert"] = "none"
|
||||
|
||||
if self.is_group:
|
||||
|
||||
@@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity):
|
||||
"""Move the shade to a position."""
|
||||
current_hass_position = hd_position_to_hass(self._current_cover_position)
|
||||
steps_to_move = abs(current_hass_position - target_hass_position)
|
||||
if not steps_to_move:
|
||||
return
|
||||
self._async_schedule_update_for_transition(steps_to_move)
|
||||
self._async_update_from_command(
|
||||
await self._shade.move(
|
||||
|
||||
@@ -470,7 +470,7 @@ class LIFXLight(LightEntity):
|
||||
|
||||
model = product_map.get(self.bulb.product) or self.bulb.product
|
||||
if model is not None:
|
||||
info["model"] = model
|
||||
info["model"] = str(model)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LIFX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lifx",
|
||||
"requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"],
|
||||
"requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"],
|
||||
"homekit": {
|
||||
"models": ["LIFX"]
|
||||
},
|
||||
|
||||
@@ -243,7 +243,7 @@ class ModbusHub:
|
||||
self._msg_wait = 0
|
||||
|
||||
def _log_error(self, text: str, error_state=True):
|
||||
log_text = f"Pymodbus: {text}"
|
||||
log_text = f"Pymodbus: {self.name}: {text}"
|
||||
if self._in_error:
|
||||
_LOGGER.debug(log_text)
|
||||
else:
|
||||
|
||||
@@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_hassio(self, discovery_info):
|
||||
"""Receive a Hass.io discovery."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
self._hassio_discovery = discovery_info
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -361,13 +361,6 @@ class NmapDeviceScanner:
|
||||
continue
|
||||
|
||||
formatted_mac = format_mac(mac)
|
||||
new = formatted_mac not in devices.tracked
|
||||
if (
|
||||
new
|
||||
and formatted_mac not in devices.tracked
|
||||
and formatted_mac not in self._known_mac_addresses
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
devices.config_entry_owner.setdefault(formatted_mac, entry_id)
|
||||
@@ -382,6 +375,7 @@ class NmapDeviceScanner:
|
||||
formatted_mac, hostname, name, ipv4, vendor, reason, now, 0
|
||||
)
|
||||
|
||||
new = formatted_mac not in devices.tracked
|
||||
devices.tracked[formatted_mac] = device
|
||||
devices.ipv4_last_mac[ipv4] = formatted_mac
|
||||
self._last_results.append(device)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "ReCollect Waste",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/recollect_waste",
|
||||
"requirements": ["aiorecollect==1.0.7"],
|
||||
"requirements": ["aiorecollect==1.0.8"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.util.dt import as_utc
|
||||
|
||||
from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER
|
||||
|
||||
@@ -124,7 +123,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity):
|
||||
ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names(
|
||||
self._entry, next_pickup_event.pickup_types
|
||||
),
|
||||
ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(),
|
||||
ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(),
|
||||
}
|
||||
)
|
||||
self._attr_native_value = as_utc(pickup_event.date).isoformat()
|
||||
self._attr_native_value = pickup_event.date.isoformat()
|
||||
|
||||
@@ -70,7 +70,7 @@ DOUBLE_TYPE = (
|
||||
Float()
|
||||
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql")
|
||||
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
|
||||
.with_variant(postgresql.DOUBLE_PRECISION, "postgresql")
|
||||
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
|
||||
)
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore
|
||||
class StatisticMetaData(TypedDict, total=False):
|
||||
"""Statistic meta data class."""
|
||||
|
||||
statistic_id: str
|
||||
unit_of_measurement: str | None
|
||||
has_mean: bool
|
||||
has_sum: bool
|
||||
|
||||
@@ -53,6 +53,13 @@ QUERY_STATISTIC_META = [
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
StatisticsMeta.unit_of_measurement,
|
||||
StatisticsMeta.has_mean,
|
||||
StatisticsMeta.has_sum,
|
||||
]
|
||||
|
||||
QUERY_STATISTIC_META_ID = [
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
]
|
||||
|
||||
STATISTICS_BAKERY = "recorder_statistics_bakery"
|
||||
@@ -124,33 +131,61 @@ def _get_metadata_ids(
|
||||
) -> list[str]:
|
||||
"""Resolve metadata_id for a list of statistic_ids."""
|
||||
baked_query = hass.data[STATISTICS_META_BAKERY](
|
||||
lambda session: session.query(*QUERY_STATISTIC_META)
|
||||
lambda session: session.query(*QUERY_STATISTIC_META_ID)
|
||||
)
|
||||
baked_query += lambda q: q.filter(
|
||||
StatisticsMeta.statistic_id.in_(bindparam("statistic_ids"))
|
||||
)
|
||||
result = execute(baked_query(session).params(statistic_ids=statistic_ids))
|
||||
|
||||
return [id for id, _, _ in result] if result else []
|
||||
return [id for id, _ in result] if result else []
|
||||
|
||||
|
||||
def _get_or_add_metadata_id(
|
||||
def _update_or_add_metadata(
|
||||
hass: HomeAssistant,
|
||||
session: scoped_session,
|
||||
statistic_id: str,
|
||||
metadata: StatisticMetaData,
|
||||
new_metadata: StatisticMetaData,
|
||||
) -> str:
|
||||
"""Get metadata_id for a statistic_id, add if it doesn't exist."""
|
||||
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
|
||||
if not metadata_id:
|
||||
unit = metadata["unit_of_measurement"]
|
||||
has_mean = metadata["has_mean"]
|
||||
has_sum = metadata["has_sum"]
|
||||
old_metadata_dict = _get_metadata(hass, session, [statistic_id], None)
|
||||
if not old_metadata_dict:
|
||||
unit = new_metadata["unit_of_measurement"]
|
||||
has_mean = new_metadata["has_mean"]
|
||||
has_sum = new_metadata["has_sum"]
|
||||
session.add(
|
||||
StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum)
|
||||
)
|
||||
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
|
||||
return metadata_id[0]
|
||||
metadata_ids = _get_metadata_ids(hass, session, [statistic_id])
|
||||
_LOGGER.debug(
|
||||
"Added new statistics metadata for %s, new_metadata: %s",
|
||||
statistic_id,
|
||||
new_metadata,
|
||||
)
|
||||
return metadata_ids[0]
|
||||
|
||||
metadata_id, old_metadata = next(iter(old_metadata_dict.items()))
|
||||
if (
|
||||
old_metadata["has_mean"] != new_metadata["has_mean"]
|
||||
or old_metadata["has_sum"] != new_metadata["has_sum"]
|
||||
or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"]
|
||||
):
|
||||
session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update(
|
||||
{
|
||||
StatisticsMeta.has_mean: new_metadata["has_mean"],
|
||||
StatisticsMeta.has_sum: new_metadata["has_sum"],
|
||||
StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"],
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s",
|
||||
statistic_id,
|
||||
old_metadata,
|
||||
new_metadata,
|
||||
)
|
||||
|
||||
return metadata_id
|
||||
|
||||
|
||||
@retryable_database_job("statistics")
|
||||
@@ -177,7 +212,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool:
|
||||
with session_scope(session=instance.get_session()) as session: # type: ignore
|
||||
for stats in platform_stats:
|
||||
for entity_id, stat in stats.items():
|
||||
metadata_id = _get_or_add_metadata_id(
|
||||
metadata_id = _update_or_add_metadata(
|
||||
instance.hass, session, entity_id, stat["meta"]
|
||||
)
|
||||
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
|
||||
@@ -191,14 +226,19 @@ def _get_metadata(
|
||||
session: scoped_session,
|
||||
statistic_ids: list[str] | None,
|
||||
statistic_type: str | None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
) -> dict[str, StatisticMetaData]:
|
||||
"""Fetch meta data."""
|
||||
|
||||
def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None:
|
||||
meta = None
|
||||
for metadata_id, statistic_id, unit in metas:
|
||||
def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None:
|
||||
meta: StatisticMetaData | None = None
|
||||
for metadata_id, statistic_id, unit, has_mean, has_sum in metas:
|
||||
if metadata_id == wanted_metadata_id:
|
||||
meta = {"unit_of_measurement": unit, "statistic_id": statistic_id}
|
||||
meta = {
|
||||
"statistic_id": statistic_id,
|
||||
"unit_of_measurement": unit,
|
||||
"has_mean": has_mean,
|
||||
"has_sum": has_sum,
|
||||
}
|
||||
return meta
|
||||
|
||||
baked_query = hass.data[STATISTICS_META_BAKERY](
|
||||
@@ -219,7 +259,7 @@ def _get_metadata(
|
||||
return {}
|
||||
|
||||
metadata_ids = [metadata[0] for metadata in result]
|
||||
metadata = {}
|
||||
metadata: dict[str, StatisticMetaData] = {}
|
||||
for _id in metadata_ids:
|
||||
meta = _meta(result, _id)
|
||||
if meta:
|
||||
@@ -230,7 +270,7 @@ def _get_metadata(
|
||||
def get_metadata(
|
||||
hass: HomeAssistant,
|
||||
statistic_id: str,
|
||||
) -> dict[str, str] | None:
|
||||
) -> StatisticMetaData | None:
|
||||
"""Return metadata for a statistic_id."""
|
||||
statistic_ids = [statistic_id]
|
||||
with session_scope(hass=hass) as session:
|
||||
@@ -255,7 +295,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str:
|
||||
|
||||
def list_statistic_ids(
|
||||
hass: HomeAssistant, statistic_type: str | None = None
|
||||
) -> list[dict[str, str] | None]:
|
||||
) -> list[StatisticMetaData | None]:
|
||||
"""Return statistic_ids and meta data."""
|
||||
units = hass.config.units
|
||||
statistic_ids = {}
|
||||
@@ -263,7 +303,9 @@ def list_statistic_ids(
|
||||
metadata = _get_metadata(hass, session, None, statistic_type)
|
||||
|
||||
for meta in metadata.values():
|
||||
unit = _configured_unit(meta["unit_of_measurement"], units)
|
||||
unit = meta["unit_of_measurement"]
|
||||
if unit is not None:
|
||||
unit = _configured_unit(unit, units)
|
||||
meta["unit_of_measurement"] = unit
|
||||
|
||||
statistic_ids = {
|
||||
@@ -277,7 +319,8 @@ def list_statistic_ids(
|
||||
platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type)
|
||||
|
||||
for statistic_id, unit in platform_statistic_ids.items():
|
||||
unit = _configured_unit(unit, units)
|
||||
if unit is not None:
|
||||
unit = _configured_unit(unit, units)
|
||||
platform_statistic_ids[statistic_id] = unit
|
||||
|
||||
statistic_ids = {**statistic_ids, **platform_statistic_ids}
|
||||
@@ -367,7 +410,7 @@ def _sorted_statistics_to_dict(
|
||||
hass: HomeAssistant,
|
||||
stats: list,
|
||||
statistic_ids: list[str] | None,
|
||||
metadata: dict[str, dict[str, str]],
|
||||
metadata: dict[str, StatisticMetaData],
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Convert SQL results into JSON friendly data structure."""
|
||||
result: dict = defaultdict(list)
|
||||
|
||||
@@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self._last_event = None
|
||||
self._last_video_id = None
|
||||
self._video_url = None
|
||||
self._image = None
|
||||
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
@@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self._last_event = None
|
||||
self._last_video_id = None
|
||||
self._video_url = None
|
||||
self._image = None
|
||||
self._expires_at = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
if self._video_url is None:
|
||||
return
|
||||
if self._image is None and self._video_url:
|
||||
image = await ffmpeg.async_get_image(
|
||||
self.hass,
|
||||
self._video_url,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
return await ffmpeg.async_get_image(
|
||||
self.hass, self._video_url, width=width, height=height
|
||||
)
|
||||
if image:
|
||||
self._image = image
|
||||
|
||||
return self._image
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
@@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera):
|
||||
if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at:
|
||||
return
|
||||
|
||||
if self._last_video_id != self._last_event["id"]:
|
||||
self._image = None
|
||||
|
||||
try:
|
||||
video_url = await self.hass.async_add_executor_job(
|
||||
self._device.recording_url, self._last_event["id"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ring",
|
||||
"name": "Ring",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ring",
|
||||
"requirements": ["ring_doorbell==0.6.2"],
|
||||
"requirements": ["ring_doorbell==0.7.1"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -108,6 +108,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = {
|
||||
}
|
||||
|
||||
# Keep track of entities for which a warning about decreasing value has been logged
|
||||
SEEN_DIP = "sensor_seen_total_increasing_dip"
|
||||
WARN_DIP = "sensor_warn_total_increasing_dip"
|
||||
# Keep track of entities for which a warning about unsupported unit has been logged
|
||||
WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit"
|
||||
@@ -233,7 +234,17 @@ def _normalize_states(
|
||||
|
||||
|
||||
def warn_dip(hass: HomeAssistant, entity_id: str) -> None:
|
||||
"""Log a warning once if a sensor with state_class_total has a decreasing value."""
|
||||
"""Log a warning once if a sensor with state_class_total has a decreasing value.
|
||||
|
||||
The log will be suppressed until two dips have been seen to prevent warning due to
|
||||
rounding issues with databases storing the state as a single precision float, which
|
||||
was fixed in recorder DB version 20.
|
||||
"""
|
||||
if SEEN_DIP not in hass.data:
|
||||
hass.data[SEEN_DIP] = set()
|
||||
if entity_id not in hass.data[SEEN_DIP]:
|
||||
hass.data[SEEN_DIP].add(entity_id)
|
||||
return
|
||||
if WARN_DIP not in hass.data:
|
||||
hass.data[WARN_DIP] = set()
|
||||
if entity_id not in hass.data[WARN_DIP]:
|
||||
@@ -341,7 +352,7 @@ def compile_statistics(
|
||||
# We have compiled history for this sensor before, use that as a starting point
|
||||
last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"]
|
||||
new_state = old_state = last_stats[entity_id][0]["state"]
|
||||
_sum = last_stats[entity_id][0]["sum"]
|
||||
_sum = last_stats[entity_id][0]["sum"] or 0
|
||||
|
||||
for fstate, state in fstates:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==11.0.4"],
|
||||
"requirements": ["simplisafe-python==11.0.5"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._device.device_id}.{self.report_name}"
|
||||
return f"{self._device.device_id}.{self.report_name}_meter"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
|
||||
@@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the flow."""
|
||||
self._reauth = False
|
||||
self._entry_id = None
|
||||
self._entry_data = {}
|
||||
self.entry = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._reauth = True
|
||||
self._entry_data = dict(data)
|
||||
entry = await self.async_set_unique_id(self.unique_id)
|
||||
self._entry_id = entry.entry_id
|
||||
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={"host": self._entry_data[CONF_HOST]},
|
||||
description_placeholders={"host": self.entry.data[CONF_HOST]},
|
||||
data_schema=vol.Schema({}),
|
||||
errors={},
|
||||
)
|
||||
@@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
if self._reauth:
|
||||
user_input = {**self._entry_data, **user_input}
|
||||
if self.entry:
|
||||
user_input = {**self.entry.data, **user_input}
|
||||
|
||||
if CONF_VERIFY_SSL not in user_input:
|
||||
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
|
||||
@@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
else:
|
||||
if self._reauth:
|
||||
return await self._async_reauth_update_entry(
|
||||
self._entry_id, user_input
|
||||
)
|
||||
if self.entry:
|
||||
return await self._async_reauth_update_entry(user_input)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
@@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult:
|
||||
async def _async_reauth_update_entry(self, data: dict) -> FlowResult:
|
||||
"""Update existing config entry."""
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
self.hass.config_entries.async_update_entry(entry, data=data)
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
def _get_user_data_schema(self) -> dict[str, Any]:
|
||||
"""Get the data schema to display user form."""
|
||||
if self._reauth:
|
||||
if self.entry:
|
||||
return {vol.Required(CONF_API_KEY): str}
|
||||
|
||||
data_schema = {
|
||||
|
||||
@@ -223,6 +223,7 @@ async def async_setup_entry(
|
||||
{
|
||||
vol.Required(ATTR_ALARM_ID): cv.positive_int,
|
||||
vol.Optional(ATTR_TIME): cv.time,
|
||||
vol.Optional(ATTR_VOLUME): cv.small_float,
|
||||
vol.Optional(ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
|
||||
},
|
||||
|
||||
@@ -323,6 +323,18 @@ class SonosSpeaker:
|
||||
async def async_subscribe(self) -> bool:
|
||||
"""Initiate event subscriptions."""
|
||||
_LOGGER.debug("Creating subscriptions for %s", self.zone_name)
|
||||
|
||||
# Create a polling task in case subscriptions fail or callback events do not arrive
|
||||
if not self._poll_timer:
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.set_basic_info)
|
||||
|
||||
@@ -337,10 +349,10 @@ class SonosSpeaker:
|
||||
for service in SUBSCRIPTION_SERVICES
|
||||
]
|
||||
await asyncio.gather(*subscriptions)
|
||||
return True
|
||||
except SoCoException as ex:
|
||||
_LOGGER.warning("Could not connect %s: %s", self.zone_name, ex)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _subscribe(
|
||||
self, target: SubscriptionBase, sub_callback: Callable
|
||||
@@ -497,15 +509,6 @@ class SonosSpeaker:
|
||||
self.soco.ip_address,
|
||||
)
|
||||
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
if self._is_ready and not self.subscriptions_failed:
|
||||
done = await self.async_subscribe()
|
||||
if not done:
|
||||
@@ -567,15 +570,6 @@ class SonosSpeaker:
|
||||
self._seen_timer = self.hass.helpers.event.async_call_later(
|
||||
SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen
|
||||
)
|
||||
if not self._poll_timer:
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
self.async_write_entity_states()
|
||||
|
||||
#
|
||||
|
||||
@@ -286,6 +286,11 @@ class Scanner:
|
||||
if header_st is not None:
|
||||
self.seen.add((header_st, header_location))
|
||||
|
||||
def _async_unsee(self, header_st: str | None, header_location: str | None) -> None:
|
||||
"""If we see a device in a new location, unsee the original location."""
|
||||
if header_st is not None:
|
||||
self.seen.remove((header_st, header_location))
|
||||
|
||||
async def _async_process_entry(self, headers: Mapping[str, str]) -> None:
|
||||
"""Process SSDP entries."""
|
||||
_LOGGER.debug("_async_process_entry: %s", headers)
|
||||
@@ -293,7 +298,12 @@ class Scanner:
|
||||
h_location = headers.get("location")
|
||||
|
||||
if h_st and (udn := _udn_from_usn(headers.get("usn"))):
|
||||
self.cache[(udn, h_st)] = headers
|
||||
cache_key = (udn, h_st)
|
||||
if old_headers := self.cache.get(cache_key):
|
||||
old_h_location = old_headers.get("location")
|
||||
if h_location != old_h_location:
|
||||
self._async_unsee(old_headers.get("st"), old_h_location)
|
||||
self.cache[cache_key] = headers
|
||||
|
||||
callbacks = self._async_get_matching_callbacks(headers)
|
||||
if self._async_seen(h_st, h_location) and not callbacks:
|
||||
|
||||
@@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
|
||||
return
|
||||
|
||||
if self.home_variable == "outdoor temperature":
|
||||
self._state = self.hass.config.units.temperature(
|
||||
self._tado_weather_data["outsideTemperature"]["celsius"],
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
self._state = self._tado_weather_data["outsideTemperature"]["celsius"]
|
||||
self._state_attributes = {
|
||||
"time": self._tado_weather_data["outsideTemperature"]["timestamp"],
|
||||
}
|
||||
@@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
if self.zone_variable == "temperature":
|
||||
return self.hass.config.units.temperature_unit
|
||||
return TEMP_CELSIUS
|
||||
if self.zone_variable == "humidity":
|
||||
return PERCENTAGE
|
||||
if self.zone_variable == "heating":
|
||||
@@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
|
||||
return
|
||||
|
||||
if self.zone_variable == "temperature":
|
||||
self._state = self.hass.config.units.temperature(
|
||||
self._tado_zone_data.current_temp, TEMP_CELSIUS
|
||||
)
|
||||
self._state = self._tado_zone_data.current_temp
|
||||
self._state_attributes = {
|
||||
"time": self._tado_zone_data.current_temp_timestamp,
|
||||
"setting": 0, # setting is used in climate device
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -72,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _fnmatch_lower(name: str | None, pattern: str) -> bool:
|
||||
"""Match a lowercase version of the name."""
|
||||
if name is None:
|
||||
return False
|
||||
return fnmatch.fnmatch(name.lower(), pattern)
|
||||
|
||||
|
||||
class USBDiscovery:
|
||||
"""Manage USB Discovery."""
|
||||
|
||||
@@ -119,7 +127,13 @@ class USBDiscovery:
|
||||
return
|
||||
|
||||
monitor = Monitor.from_netlink(context)
|
||||
monitor.filter_by(subsystem="tty")
|
||||
try:
|
||||
monitor.filter_by(subsystem="tty")
|
||||
except ValueError as ex: # this fails on WSL
|
||||
_LOGGER.debug(
|
||||
"Unable to setup pyudev filtering; This is expected on WSL: %s", ex
|
||||
)
|
||||
return
|
||||
observer = MonitorObserver(
|
||||
monitor, callback=self._device_discovered, name="usb-observer"
|
||||
)
|
||||
@@ -152,6 +166,18 @@ class USBDiscovery:
|
||||
continue
|
||||
if "pid" in matcher and device.pid != matcher["pid"]:
|
||||
continue
|
||||
if "serial_number" in matcher and not _fnmatch_lower(
|
||||
device.serial_number, matcher["serial_number"]
|
||||
):
|
||||
continue
|
||||
if "manufacturer" in matcher and not _fnmatch_lower(
|
||||
device.manufacturer, matcher["manufacturer"]
|
||||
):
|
||||
continue
|
||||
if "description" in matcher and not _fnmatch_lower(
|
||||
device.description, matcher["description"]
|
||||
):
|
||||
continue
|
||||
flow: USBFlow = {
|
||||
"domain": matcher["domain"],
|
||||
"context": {"source": config_entries.SOURCE_USB},
|
||||
|
||||
@@ -87,7 +87,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
source = HaVersionSource.CONTAINER
|
||||
|
||||
if (
|
||||
source in (HaVersionSource.SUPERVISOR, HaVersionSource.CONTAINER)
|
||||
source == HaVersionSource.CONTAINER
|
||||
and image is not None
|
||||
and image != DEFAULT_IMAGE
|
||||
):
|
||||
|
||||
@@ -333,7 +333,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity):
|
||||
}
|
||||
)
|
||||
self._mode = self._state_attrs.get(ATTR_MODE)
|
||||
self._fan_level = self.coordinator.data.fan_level
|
||||
self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None)
|
||||
self.async_write_ha_state()
|
||||
|
||||
#
|
||||
@@ -440,7 +440,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice):
|
||||
{attribute: None for attribute in self._available_attributes}
|
||||
)
|
||||
self._mode = self._state_attrs.get(ATTR_MODE)
|
||||
self._fan_level = self.coordinator.data.fan_level
|
||||
self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None)
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
|
||||
@@ -196,7 +196,6 @@ async def _async_initialize(
|
||||
entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {
|
||||
DATA_PLATFORMS_LOADED: False
|
||||
}
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
@callback
|
||||
def _async_load_platforms():
|
||||
@@ -212,6 +211,15 @@ async def _async_initialize(
|
||||
await device.async_setup()
|
||||
entry_data[DATA_DEVICE] = device
|
||||
|
||||
if (
|
||||
device.capabilities
|
||||
and entry.options.get(CONF_MODEL) != device.capabilities["model"]
|
||||
):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options={**entry.options, CONF_MODEL: device.capabilities["model"]}
|
||||
)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, DEVICE_INITIALIZED.format(host), _async_load_platforms
|
||||
@@ -540,7 +548,7 @@ class YeelightDevice:
|
||||
self._config = config
|
||||
self._host = host
|
||||
self._bulb_device = bulb
|
||||
self._capabilities = {}
|
||||
self.capabilities = {}
|
||||
self._device_type = None
|
||||
self._available = False
|
||||
self._initialized = False
|
||||
@@ -574,12 +582,12 @@ class YeelightDevice:
|
||||
@property
|
||||
def model(self):
|
||||
"""Return configured/autodetected device model."""
|
||||
return self._bulb_device.model or self._capabilities.get("model")
|
||||
return self._bulb_device.model or self.capabilities.get("model")
|
||||
|
||||
@property
|
||||
def fw_version(self):
|
||||
"""Return the firmware version."""
|
||||
return self._capabilities.get("fw_ver")
|
||||
return self.capabilities.get("fw_ver")
|
||||
|
||||
@property
|
||||
def is_nightlight_supported(self) -> bool:
|
||||
@@ -674,20 +682,22 @@ class YeelightDevice:
|
||||
async def async_setup(self):
|
||||
"""Fetch capabilities and setup name if available."""
|
||||
scanner = YeelightScanner.async_get(self._hass)
|
||||
self._capabilities = await scanner.async_get_capabilities(self._host) or {}
|
||||
self.capabilities = await scanner.async_get_capabilities(self._host) or {}
|
||||
if self.capabilities:
|
||||
self._bulb_device.set_capabilities(self.capabilities)
|
||||
if name := self._config.get(CONF_NAME):
|
||||
# Override default name when name is set in config
|
||||
self._name = name
|
||||
elif self._capabilities:
|
||||
elif self.capabilities:
|
||||
# Generate name from model and id when capabilities is available
|
||||
self._name = _async_unique_name(self._capabilities)
|
||||
self._name = _async_unique_name(self.capabilities)
|
||||
else:
|
||||
self._name = self._host # Default name is host
|
||||
|
||||
async def async_update(self):
|
||||
async def async_update(self, force=False):
|
||||
"""Update device properties and send data updated signal."""
|
||||
if self._initialized and self._available:
|
||||
# No need to poll, already connected
|
||||
if not force and self._initialized and self._available:
|
||||
# No need to poll unless force, already connected
|
||||
return
|
||||
await self._async_update_properties()
|
||||
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
|
||||
|
||||
@@ -96,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=async_format_model_id(self._discovered_model, self.unique_id),
|
||||
data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip},
|
||||
data={
|
||||
CONF_ID: self.unique_id,
|
||||
CONF_HOST: self._discovered_ip,
|
||||
CONF_MODEL: self._discovered_model,
|
||||
},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
@@ -129,6 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_ID: self.unique_id,
|
||||
CONF_MODEL: model,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,7 +156,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
host = urlparse(capabilities["location"]).hostname
|
||||
return self.async_create_entry(
|
||||
title=_async_unique_name(capabilities),
|
||||
data={CONF_ID: unique_id, CONF_HOST: host},
|
||||
data={
|
||||
CONF_ID: unique_id,
|
||||
CONF_HOST: host,
|
||||
CONF_MODEL: capabilities["model"],
|
||||
},
|
||||
)
|
||||
|
||||
configured_devices = {
|
||||
|
||||
@@ -762,6 +762,10 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
|
||||
_LOGGER.error("Unable to set the defaults: %s", ex)
|
||||
return
|
||||
|
||||
# Some devices (mainly nightlights) will not send back the on state so we need to force a refresh
|
||||
if not self.is_on:
|
||||
await self.device.async_update(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off."""
|
||||
if not self.is_on:
|
||||
@@ -772,6 +776,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
|
||||
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
|
||||
|
||||
await self.device.async_turn_off(duration=duration, light_type=self.light_type)
|
||||
# Some devices will not send back the off state so we need to force a refresh
|
||||
if self.is_on:
|
||||
await self.device.async_update(True)
|
||||
|
||||
async def async_set_mode(self, mode: str):
|
||||
"""Set a power mode."""
|
||||
@@ -852,7 +859,12 @@ class YeelightColorLightWithoutNightlightSwitch(
|
||||
|
||||
@property
|
||||
def _brightness_property(self):
|
||||
return "current_brightness"
|
||||
# If the nightlight is not active, we do not
|
||||
# want to "current_brightness" since it will check
|
||||
# "bg_power" and main light could still be on
|
||||
if self.device.is_nightlight_enabled:
|
||||
return "current_brightness"
|
||||
return super()._brightness_property
|
||||
|
||||
|
||||
class YeelightColorLightWithNightlightSwitch(
|
||||
@@ -876,7 +888,12 @@ class YeelightWhiteTempWithoutNightlightSwitch(
|
||||
|
||||
@property
|
||||
def _brightness_property(self):
|
||||
return "current_brightness"
|
||||
# If the nightlight is not active, we do not
|
||||
# want to "current_brightness" since it will check
|
||||
# "bg_power" and main light could still be on
|
||||
if self.device.is_nightlight_enabled:
|
||||
return "current_brightness"
|
||||
return super()._brightness_property
|
||||
|
||||
|
||||
class YeelightWithNightLight(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "yeelight",
|
||||
"name": "Yeelight",
|
||||
"documentation": "https://www.home-assistant.io/integrations/yeelight",
|
||||
"requirements": ["yeelight==0.7.3", "async-upnp-client==0.20.0"],
|
||||
"requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"],
|
||||
"codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
|
||||
@@ -25,6 +25,7 @@ SUPPORTED_PORT_SETTINGS = (
|
||||
CONF_BAUDRATE,
|
||||
CONF_FLOWCONTROL,
|
||||
)
|
||||
DECONZ_DOMAIN = "deconz"
|
||||
|
||||
|
||||
class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -36,7 +37,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Initialize flow instance."""
|
||||
self._device_path = None
|
||||
self._radio_type = None
|
||||
self._auto_detected_data = None
|
||||
self._title = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
@@ -121,18 +121,12 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# we ignore the usb discovery as they probably
|
||||
# want to use it there instead
|
||||
for flow in self.hass.config_entries.flow.async_progress():
|
||||
if flow["handler"] == "deconz":
|
||||
if flow["handler"] == DECONZ_DOMAIN:
|
||||
return self.async_abort(reason="not_zha_device")
|
||||
for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN):
|
||||
if entry.source != config_entries.SOURCE_IGNORE:
|
||||
return self.async_abort(reason="not_zha_device")
|
||||
|
||||
# The Nortek sticks are a special case since they
|
||||
# have a Z-Wave and a Zigbee radio. We need to reject
|
||||
# the Z-Wave radio.
|
||||
if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description:
|
||||
return self.async_abort(reason="not_zha_device")
|
||||
|
||||
self._auto_detected_data = await detect_radios(dev_path)
|
||||
if self._auto_detected_data is None:
|
||||
return self.async_abort(reason="not_zha_device")
|
||||
self._device_path = dev_path
|
||||
self._title = usb.human_readable_device_name(
|
||||
dev_path,
|
||||
@@ -149,9 +143,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_confirm(self, user_input=None):
|
||||
"""Confirm a discovery."""
|
||||
if user_input is not None:
|
||||
auto_detected_data = await detect_radios(self._device_path)
|
||||
if auto_detected_data is None:
|
||||
# This probably will not happen how they have
|
||||
# have very specific usb matching, but there could
|
||||
# be a problem with the device
|
||||
return self.async_abort(reason="usb_probe_failed")
|
||||
return self.async_create_entry(
|
||||
title=self._title,
|
||||
data=self._auto_detected_data,
|
||||
data=auto_detected_data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -4,22 +4,21 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zha",
|
||||
"requirements": [
|
||||
"bellows==0.26.0",
|
||||
"bellows==0.27.0",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.5",
|
||||
"zha-quirks==0.0.59",
|
||||
"zha-quirks==0.0.60",
|
||||
"zigpy-cc==0.5.2",
|
||||
"zigpy-deconz==0.12.1",
|
||||
"zigpy==0.36.1",
|
||||
"zigpy-xbee==0.13.0",
|
||||
"zigpy-deconz==0.13.0",
|
||||
"zigpy==0.37.1",
|
||||
"zigpy-xbee==0.14.0",
|
||||
"zigpy-zigate==0.7.3",
|
||||
"zigpy-znp==0.5.3"
|
||||
"zigpy-znp==0.5.4"
|
||||
],
|
||||
"usb": [
|
||||
{"vid":"10C4","pid":"EA60","known_devices":["slae.sh cc2652rb stick"]},
|
||||
{"vid":"1CF1","pid":"0030","known_devices":["Conbee II"]},
|
||||
{"vid":"1A86","pid":"7523","known_devices":["Electrolama zig-a-zig-ah"]},
|
||||
{"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}
|
||||
{"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]},
|
||||
{"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]},
|
||||
{"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]}
|
||||
],
|
||||
"codeowners": ["@dmulcahey", "@adminiuga"],
|
||||
"zeroconf": [
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"not_zha_device": "This device is not a zha device"
|
||||
"not_zha_device": "This device is not a zha device",
|
||||
"usb_probe_failed": "Failed to probe the usb device"
|
||||
}
|
||||
},
|
||||
"config_panel": {
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Final
|
||||
|
||||
MAJOR_VERSION: Final = 2021
|
||||
MINOR_VERSION: Final = 9
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)
|
||||
|
||||
@@ -9,22 +9,20 @@ USB = [
|
||||
{
|
||||
"domain": "zha",
|
||||
"vid": "10C4",
|
||||
"pid": "EA60"
|
||||
"pid": "EA60",
|
||||
"description": "*2652*"
|
||||
},
|
||||
{
|
||||
"domain": "zha",
|
||||
"vid": "1CF1",
|
||||
"pid": "0030"
|
||||
},
|
||||
{
|
||||
"domain": "zha",
|
||||
"vid": "1A86",
|
||||
"pid": "7523"
|
||||
"pid": "0030",
|
||||
"description": "*conbee*"
|
||||
},
|
||||
{
|
||||
"domain": "zha",
|
||||
"vid": "10C4",
|
||||
"pid": "8A2A"
|
||||
"pid": "8A2A",
|
||||
"description": "*zigbee*"
|
||||
},
|
||||
{
|
||||
"domain": "zwave_js",
|
||||
|
||||
@@ -201,7 +201,7 @@ aiokafka==0.6.0
|
||||
aiokef==0.2.16
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx==0.6.9
|
||||
aiolifx==0.6.10
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx_effects==0.2.2
|
||||
@@ -237,7 +237,7 @@ aiopvpc==2.2.0
|
||||
aiopylgtv==0.4.0
|
||||
|
||||
# homeassistant.components.recollect_waste
|
||||
aiorecollect==1.0.7
|
||||
aiorecollect==1.0.8
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==0.6.4
|
||||
@@ -372,10 +372,10 @@ beautifulsoup4==4.9.3
|
||||
# beewi_smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.26.0
|
||||
bellows==0.27.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.7.19
|
||||
bimmer_connected==0.7.20
|
||||
|
||||
# homeassistant.components.bizkaibus
|
||||
bizkaibus==0.1.1
|
||||
@@ -2043,7 +2043,7 @@ rfk101py==0.0.1
|
||||
rflink==0.0.58
|
||||
|
||||
# homeassistant.components.ring
|
||||
ring_doorbell==0.6.2
|
||||
ring_doorbell==0.7.1
|
||||
|
||||
# homeassistant.components.fleetgo
|
||||
ritassist==0.9.2
|
||||
@@ -2131,7 +2131,7 @@ simplehound==0.3
|
||||
simplepush==1.1.4
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==11.0.4
|
||||
simplisafe-python==11.0.5
|
||||
|
||||
# homeassistant.components.sisyphus
|
||||
sisyphus-control==3.0
|
||||
@@ -2438,7 +2438,7 @@ yalesmartalarmclient==0.3.4
|
||||
yalexs==1.1.13
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.3
|
||||
yeelight==0.7.4
|
||||
|
||||
# homeassistant.components.yeelightsunflower
|
||||
yeelightsunflower==0.0.10
|
||||
@@ -2459,7 +2459,7 @@ zengge==0.2
|
||||
zeroconf==0.36.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.59
|
||||
zha-quirks==0.0.60
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
@@ -2471,19 +2471,19 @@ ziggo-mediabox-xl==1.1.0
|
||||
zigpy-cc==0.5.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.12.1
|
||||
zigpy-deconz==0.13.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-xbee==0.13.0
|
||||
zigpy-xbee==0.14.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-zigate==0.7.3
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-znp==0.5.3
|
||||
zigpy-znp==0.5.4
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.36.1
|
||||
zigpy==0.37.1
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.5.2
|
||||
|
||||
@@ -158,7 +158,7 @@ aiopvpc==2.2.0
|
||||
aiopylgtv==0.4.0
|
||||
|
||||
# homeassistant.components.recollect_waste
|
||||
aiorecollect==1.0.7
|
||||
aiorecollect==1.0.8
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==0.6.4
|
||||
@@ -227,10 +227,10 @@ azure-eventhub==5.5.0
|
||||
base36==0.1.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.26.0
|
||||
bellows==0.27.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.7.19
|
||||
bimmer_connected==0.7.20
|
||||
|
||||
# homeassistant.components.blebox
|
||||
blebox_uniapi==1.3.3
|
||||
@@ -1148,7 +1148,7 @@ restrictedpython==5.1
|
||||
rflink==0.0.58
|
||||
|
||||
# homeassistant.components.ring
|
||||
ring_doorbell==0.6.2
|
||||
ring_doorbell==0.7.1
|
||||
|
||||
# homeassistant.components.roku
|
||||
rokuecp==0.8.1
|
||||
@@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8
|
||||
simplehound==0.3
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==11.0.4
|
||||
simplisafe-python==11.0.5
|
||||
|
||||
# homeassistant.components.slack
|
||||
slackclient==2.5.0
|
||||
@@ -1367,7 +1367,7 @@ yalesmartalarmclient==0.3.4
|
||||
yalexs==1.1.13
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.3
|
||||
yeelight==0.7.4
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==0.12
|
||||
@@ -1379,25 +1379,25 @@ zeep[async]==4.0.0
|
||||
zeroconf==0.36.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.59
|
||||
zha-quirks==0.0.60
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-cc==0.5.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.12.1
|
||||
zigpy-deconz==0.13.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-xbee==0.13.0
|
||||
zigpy-xbee==0.14.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-zigate==0.7.3
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-znp==0.5.3
|
||||
zigpy-znp==0.5.4
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.36.1
|
||||
zigpy==0.37.1
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.29.0
|
||||
|
||||
@@ -210,6 +210,9 @@ MANIFEST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("vid"): vol.All(str, verify_uppercase),
|
||||
vol.Optional("pid"): vol.All(str, verify_uppercase),
|
||||
vol.Optional("serial_number"): vol.All(str, verify_lowercase),
|
||||
vol.Optional("manufacturer"): vol.All(str, verify_lowercase),
|
||||
vol.Optional("description"): vol.All(str, verify_lowercase),
|
||||
vol.Optional("known_devices"): [str],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,6 +77,28 @@ async def test_get_image_from_camera(hass, image_mock_url):
|
||||
assert image.content == b"Test"
|
||||
|
||||
|
||||
async def test_legacy_async_get_image_signature_warns_only_once(
|
||||
hass, image_mock_url, caplog
|
||||
):
|
||||
"""Test that we only warn once when we encounter a legacy async_get_image function signature."""
|
||||
|
||||
async def _legacy_async_camera_image(self):
|
||||
return b"Image"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.async_camera_image",
|
||||
new=_legacy_async_camera_image,
|
||||
):
|
||||
image = await camera.async_get_image(hass, "camera.demo_camera")
|
||||
assert image.content == b"Image"
|
||||
assert "does not support requesting width and height" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
image = await camera.async_get_image(hass, "camera.demo_camera")
|
||||
assert image.content == b"Image"
|
||||
assert "does not support requesting width and height" not in caplog.text
|
||||
|
||||
|
||||
async def test_get_image_from_camera_with_width_height(hass, image_mock_url):
|
||||
"""Grab an image from camera entity with width and height."""
|
||||
|
||||
|
||||
@@ -593,6 +593,7 @@ async def test_pymodbus_constructor_fail(hass, caplog):
|
||||
config = {
|
||||
DOMAIN: [
|
||||
{
|
||||
CONF_NAME: TEST_MODBUS_NAME,
|
||||
CONF_TYPE: TCP,
|
||||
CONF_HOST: TEST_MODBUS_HOST,
|
||||
CONF_PORT: TEST_PORT_TCP,
|
||||
@@ -606,7 +607,8 @@ async def test_pymodbus_constructor_fail(hass, caplog):
|
||||
mock_pb.side_effect = ModbusException("test no class")
|
||||
assert await async_setup_component(hass, DOMAIN, config) is False
|
||||
await hass.async_block_till_done()
|
||||
assert caplog.messages[0].startswith("Pymodbus: Modbus Error: test")
|
||||
message = f"Pymodbus: {TEST_MODBUS_NAME}: Modbus Error: test"
|
||||
assert caplog.messages[0].startswith(message)
|
||||
assert caplog.records[0].levelname == "ERROR"
|
||||
assert mock_pb.called
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -100,7 +101,7 @@ async def test_user_single_instance(hass):
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_hassio_single_instance(hass):
|
||||
async def test_hassio_already_configured(hass):
|
||||
"""Test we only allow a single config flow."""
|
||||
MockConfigEntry(domain="mqtt").add_to_hass(hass)
|
||||
|
||||
@@ -108,7 +109,23 @@ async def test_hassio_single_instance(hass):
|
||||
"mqtt", context={"source": config_entries.SOURCE_HASSIO}
|
||||
)
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_hassio_ignored(hass: HomeAssistant) -> None:
|
||||
"""Test we supervisor discovered instance can be ignored."""
|
||||
MockConfigEntry(
|
||||
domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
mqtt.DOMAIN,
|
||||
data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"},
|
||||
context={"source": config_entries.SOURCE_HASSIO},
|
||||
)
|
||||
assert result
|
||||
assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result.get("reason") == "already_configured"
|
||||
|
||||
|
||||
async def test_hassio_confirm(hass, mock_try_connection, mock_finish_setup):
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.components.recorder import history
|
||||
from homeassistant.components.recorder.const import DATA_INSTANCE
|
||||
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
get_metadata,
|
||||
list_statistic_ids,
|
||||
statistics_during_period,
|
||||
)
|
||||
@@ -371,7 +372,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip(
|
||||
"state_class": "total_increasing",
|
||||
"unit_of_measurement": unit,
|
||||
}
|
||||
seq = [10, 15, 20, 19, 30, 40, 50, 60, 70]
|
||||
seq = [10, 15, 20, 19, 30, 40, 39, 60, 70]
|
||||
|
||||
four, eight, states = record_meter_states(
|
||||
hass, zero, "sensor.test1", attributes, seq
|
||||
@@ -385,8 +386,20 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip(
|
||||
wait_recording_done(hass)
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1))
|
||||
wait_recording_done(hass)
|
||||
assert (
|
||||
"Entity sensor.test1 has state class total_increasing, but its state is not "
|
||||
"strictly increasing. Please create a bug report at https://github.com/"
|
||||
"home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A"
|
||||
"+recorder%22"
|
||||
) not in caplog.text
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2))
|
||||
wait_recording_done(hass)
|
||||
assert (
|
||||
"Entity sensor.test1 has state class total_increasing, but its state is not "
|
||||
"strictly increasing. Please create a bug report at https://github.com/"
|
||||
"home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A"
|
||||
"+recorder%22"
|
||||
) in caplog.text
|
||||
statistic_ids = list_statistic_ids(hass)
|
||||
assert statistic_ids == [
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": native_unit}
|
||||
@@ -427,12 +440,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip(
|
||||
]
|
||||
}
|
||||
assert "Error while processing event StatisticsTask" not in caplog.text
|
||||
assert (
|
||||
"Entity sensor.test1 has state class total_increasing, but its state is not "
|
||||
"strictly increasing. Please create a bug report at https://github.com/"
|
||||
"home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A"
|
||||
"+recorder%22"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog):
|
||||
@@ -1031,6 +1038,95 @@ def test_compile_hourly_statistics_changing_units_2(
|
||||
assert "Error while processing event StatisticsTask" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device_class,unit,native_unit,mean,min,max",
|
||||
[
|
||||
(None, None, None, 16.440677, 10, 30),
|
||||
],
|
||||
)
|
||||
def test_compile_hourly_statistics_changing_statistics(
|
||||
hass_recorder, caplog, device_class, unit, native_unit, mean, min, max
|
||||
):
|
||||
"""Test compiling hourly statistics where units change during an hour."""
|
||||
zero = dt_util.utcnow()
|
||||
hass = hass_recorder()
|
||||
recorder = hass.data[DATA_INSTANCE]
|
||||
setup_component(hass, "sensor", {})
|
||||
attributes_1 = {
|
||||
"device_class": device_class,
|
||||
"state_class": "measurement",
|
||||
"unit_of_measurement": unit,
|
||||
}
|
||||
attributes_2 = {
|
||||
"device_class": device_class,
|
||||
"state_class": "total_increasing",
|
||||
"unit_of_measurement": unit,
|
||||
}
|
||||
four, states = record_states(hass, zero, "sensor.test1", attributes_1)
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero)
|
||||
wait_recording_done(hass)
|
||||
statistic_ids = list_statistic_ids(hass)
|
||||
assert statistic_ids == [
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": None}
|
||||
]
|
||||
metadata = get_metadata(hass, "sensor.test1")
|
||||
assert metadata == {
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
"statistic_id": "sensor.test1",
|
||||
"unit_of_measurement": None,
|
||||
}
|
||||
|
||||
# Add more states, with changed state class
|
||||
four, _states = record_states(
|
||||
hass, zero + timedelta(hours=1), "sensor.test1", attributes_2
|
||||
)
|
||||
states["sensor.test1"] += _states["sensor.test1"]
|
||||
hist = history.get_significant_states(hass, zero, four)
|
||||
assert dict(states) == dict(hist)
|
||||
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1))
|
||||
wait_recording_done(hass)
|
||||
statistic_ids = list_statistic_ids(hass)
|
||||
assert statistic_ids == [
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": None}
|
||||
]
|
||||
metadata = get_metadata(hass, "sensor.test1")
|
||||
assert metadata == {
|
||||
"has_mean": False,
|
||||
"has_sum": True,
|
||||
"statistic_id": "sensor.test1",
|
||||
"unit_of_measurement": None,
|
||||
}
|
||||
stats = statistics_during_period(hass, zero)
|
||||
assert stats == {
|
||||
"sensor.test1": [
|
||||
{
|
||||
"statistic_id": "sensor.test1",
|
||||
"start": process_timestamp_to_utc_isoformat(zero),
|
||||
"mean": approx(mean),
|
||||
"min": approx(min),
|
||||
"max": approx(max),
|
||||
"last_reset": None,
|
||||
"state": None,
|
||||
"sum": None,
|
||||
},
|
||||
{
|
||||
"statistic_id": "sensor.test1",
|
||||
"start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)),
|
||||
"mean": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"last_reset": None,
|
||||
"state": approx(30.0),
|
||||
"sum": approx(30.0),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
assert "Error while processing event StatisticsTask" not in caplog.text
|
||||
|
||||
|
||||
def record_states(hass, zero, entity_id, attributes):
|
||||
"""Record some test states.
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ async def test_power_consumption_sensor(hass, device_factory):
|
||||
assert state.state == "1412.002"
|
||||
entry = entity_registry.async_get("sensor.refrigerator_energy")
|
||||
assert entry
|
||||
assert entry.unique_id == f"{device.device_id}.energy"
|
||||
assert entry.unique_id == f"{device.device_id}.energy_meter"
|
||||
entry = device_registry.async_get_device({(DOMAIN, device.device_id)})
|
||||
assert entry
|
||||
assert entry.name == device.label
|
||||
@@ -180,7 +180,7 @@ async def test_power_consumption_sensor(hass, device_factory):
|
||||
assert state.state == "109"
|
||||
entry = entity_registry.async_get("sensor.refrigerator_power")
|
||||
assert entry
|
||||
assert entry.unique_id == f"{device.device_id}.power"
|
||||
assert entry.unique_id == f"{device.device_id}.power_meter"
|
||||
entry = device_registry.async_get_device({(DOMAIN, device.device_id)})
|
||||
assert entry
|
||||
assert entry.name == device.label
|
||||
@@ -202,7 +202,7 @@ async def test_power_consumption_sensor(hass, device_factory):
|
||||
assert state.state == "unknown"
|
||||
entry = entity_registry.async_get("sensor.vacuum_energy")
|
||||
assert entry
|
||||
assert entry.unique_id == f"{device.device_id}.energy"
|
||||
assert entry.unique_id == f"{device.device_id}.energy_meter"
|
||||
entry = device_registry.async_get_device({(DOMAIN, device.device_id)})
|
||||
assert entry
|
||||
assert entry.name == device.label
|
||||
|
||||
@@ -100,14 +100,16 @@ async def test_full_reauth_flow_implementation(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test the manual reauth flow from start to finish."""
|
||||
entry = await setup_integration(
|
||||
hass, aioclient_mock, skip_entry_setup=True, unique_id="any"
|
||||
)
|
||||
entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True)
|
||||
assert entry
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_REAUTH, "unique_id": entry.unique_id},
|
||||
context={
|
||||
CONF_SOURCE: SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
"unique_id": entry.unique_id,
|
||||
},
|
||||
data=entry.data,
|
||||
)
|
||||
|
||||
|
||||
@@ -926,3 +926,127 @@ async def test_ipv4_does_additional_search_for_sonos(hass, caplog):
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock):
|
||||
"""Test that a location change for a UDN will evict the prior location from the cache."""
|
||||
mock_get_ssdp = {
|
||||
"hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}]
|
||||
}
|
||||
|
||||
hue_response = """
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<URLBase>http://{ip_address}:80/</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||
<friendlyName>Philips hue ({ip_address})</friendlyName>
|
||||
<manufacturer>Signify</manufacturer>
|
||||
<manufacturerURL>http://www.philips-hue.com</manufacturerURL>
|
||||
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
|
||||
<modelName>Philips hue bridge 2015</modelName>
|
||||
<modelNumber>BSB002</modelNumber>
|
||||
<modelURL>http://www.philips-hue.com</modelURL>
|
||||
<serialNumber>001788a36abf</serialNumber>
|
||||
<UDN>uuid:2f402f80-da50-11e1-9b23-001788a36abf</UDN>
|
||||
</device>
|
||||
</root>
|
||||
"""
|
||||
|
||||
aioclient_mock.get(
|
||||
"http://192.168.212.23/description.xml",
|
||||
text=hue_response.format(ip_address="192.168.212.23"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://169.254.8.155/description.xml",
|
||||
text=hue_response.format(ip_address="169.254.8.155"),
|
||||
)
|
||||
ssdp_response_without_location = {
|
||||
"ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf",
|
||||
"_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf",
|
||||
"USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf",
|
||||
"SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0",
|
||||
"hue-bridgeid": "001788FFFEA36ABF",
|
||||
"EXT": "",
|
||||
}
|
||||
|
||||
mock_good_ip_ssdp_response = CaseInsensitiveDict(
|
||||
**ssdp_response_without_location,
|
||||
**{"LOCATION": "http://192.168.212.23/description.xml"},
|
||||
)
|
||||
mock_link_local_ip_ssdp_response = CaseInsensitiveDict(
|
||||
**ssdp_response_without_location,
|
||||
**{"LOCATION": "http://169.254.8.155/description.xml"},
|
||||
)
|
||||
mock_ssdp_response = mock_good_ip_ssdp_response
|
||||
|
||||
def _generate_fake_ssdp_listener(*args, **kwargs):
|
||||
listener = SSDPListener(*args, **kwargs)
|
||||
|
||||
async def _async_callback(*_):
|
||||
pass
|
||||
|
||||
@callback
|
||||
def _callback(*_):
|
||||
import pprint
|
||||
|
||||
pprint.pprint(mock_ssdp_response)
|
||||
hass.async_create_task(listener.async_callback(mock_ssdp_response))
|
||||
|
||||
listener.async_start = _async_callback
|
||||
listener.async_search = _callback
|
||||
return listener
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ssdp.async_get_ssdp",
|
||||
return_value=mock_get_ssdp,
|
||||
), patch(
|
||||
"homeassistant.components.ssdp.SSDPListener",
|
||||
new=_generate_fake_ssdp_listener,
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_init.mock_calls) == 1
|
||||
assert mock_init.mock_calls[0][1][0] == "hue"
|
||||
assert mock_init.mock_calls[0][2]["context"] == {
|
||||
"source": config_entries.SOURCE_SSDP
|
||||
}
|
||||
assert (
|
||||
mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION]
|
||||
== mock_good_ip_ssdp_response["location"]
|
||||
)
|
||||
|
||||
mock_init.reset_mock()
|
||||
mock_ssdp_response = mock_link_local_ip_ssdp_response
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400))
|
||||
await hass.async_block_till_done()
|
||||
assert mock_init.mock_calls[0][1][0] == "hue"
|
||||
assert mock_init.mock_calls[0][2]["context"] == {
|
||||
"source": config_entries.SOURCE_SSDP
|
||||
}
|
||||
assert (
|
||||
mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION]
|
||||
== mock_link_local_ip_ssdp_response["location"]
|
||||
)
|
||||
|
||||
mock_init.reset_mock()
|
||||
mock_ssdp_response = mock_good_ip_ssdp_response
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600))
|
||||
await hass.async_block_till_done()
|
||||
assert mock_init.mock_calls[0][1][0] == "hue"
|
||||
assert mock_init.mock_calls[0][2]["context"] == {
|
||||
"source": config_entries.SOURCE_SSDP
|
||||
}
|
||||
assert (
|
||||
mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION]
|
||||
== mock_good_ip_ssdp_response["location"]
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components import usb
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import slae_sh_device
|
||||
from . import conbee_device, slae_sh_device
|
||||
|
||||
|
||||
@pytest.fixture(name="operating_system")
|
||||
@@ -38,6 +38,20 @@ def mock_docker():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="venv")
|
||||
def mock_venv():
|
||||
"""Mock running Home Assistant in a venv container."""
|
||||
with patch(
|
||||
"homeassistant.components.usb.system_info.async_get_system_info",
|
||||
return_value={
|
||||
"hassio": False,
|
||||
"docker": False,
|
||||
"virtualenv": True,
|
||||
},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="Only works on linux",
|
||||
@@ -171,6 +185,297 @@ async def test_discovered_by_websocket_scan(hass, hass_ws_client):
|
||||
assert mock_config_flow.mock_calls[0][1][0] == "test1"
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_limited_by_description_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan is limited by the description matcher."""
|
||||
new_usb = [
|
||||
{"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=slae_sh_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=slae_sh_device.serial_number,
|
||||
manufacturer=slae_sh_device.manufacturer,
|
||||
description=slae_sh_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 1
|
||||
assert mock_config_flow.mock_calls[0][1][0] == "test1"
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_rejected_by_description_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan rejected by the description matcher."""
|
||||
new_usb = [
|
||||
{"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=slae_sh_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=slae_sh_device.serial_number,
|
||||
manufacturer=slae_sh_device.manufacturer,
|
||||
description=slae_sh_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan is limited by the serial_number matcher."""
|
||||
new_usb = [
|
||||
{
|
||||
"domain": "test1",
|
||||
"vid": "3039",
|
||||
"pid": "3039",
|
||||
"serial_number": "00_12_4b_00*",
|
||||
}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=slae_sh_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=slae_sh_device.serial_number,
|
||||
manufacturer=slae_sh_device.manufacturer,
|
||||
description=slae_sh_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 1
|
||||
assert mock_config_flow.mock_calls[0][1][0] == "test1"
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan is rejected by the serial_number matcher."""
|
||||
new_usb = [
|
||||
{"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=slae_sh_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=slae_sh_device.serial_number,
|
||||
manufacturer=slae_sh_device.manufacturer,
|
||||
description=slae_sh_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan is limited by the manufacturer matcher."""
|
||||
new_usb = [
|
||||
{
|
||||
"domain": "test1",
|
||||
"vid": "3039",
|
||||
"pid": "3039",
|
||||
"manufacturer": "dresden elektronik ingenieurtechnik*",
|
||||
}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=conbee_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=conbee_device.serial_number,
|
||||
manufacturer=conbee_device.manufacturer,
|
||||
description=conbee_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 1
|
||||
assert mock_config_flow.mock_calls[0][1][0] == "test1"
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket scan is rejected by the manufacturer matcher."""
|
||||
new_usb = [
|
||||
{
|
||||
"domain": "test1",
|
||||
"vid": "3039",
|
||||
"pid": "3039",
|
||||
"manufacturer": "other vendor*",
|
||||
}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=conbee_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=conbee_device.serial_number,
|
||||
manufacturer=conbee_device.manufacturer,
|
||||
description=conbee_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_rejected_with_empty_serial_number_only(
|
||||
hass, hass_ws_client
|
||||
):
|
||||
"""Test a device is discovered from websocket is rejected with empty serial number."""
|
||||
new_usb = [
|
||||
{"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"}
|
||||
]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=conbee_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=None,
|
||||
manufacturer=None,
|
||||
description=None,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=new_usb
|
||||
), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client):
|
||||
"""Test a device is discovered from websocket scan only matching vid."""
|
||||
new_usb = [{"domain": "test1", "vid": "3039"}]
|
||||
@@ -315,6 +620,48 @@ async def test_non_matching_discovered_by_scanner_after_started(
|
||||
assert len(mock_config_flow.mock_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="Only works on linux",
|
||||
)
|
||||
async def test_observer_on_wsl_fallback_without_throwing_exception(
|
||||
hass, hass_ws_client, venv
|
||||
):
|
||||
"""Test that observer on WSL failure results in fallback to scanning without raising an exception."""
|
||||
new_usb = [{"domain": "test1", "vid": "3039"}]
|
||||
|
||||
mock_comports = [
|
||||
MagicMock(
|
||||
device=slae_sh_device.device,
|
||||
vid=12345,
|
||||
pid=12345,
|
||||
serial_number=slae_sh_device.serial_number,
|
||||
manufacturer=slae_sh_device.manufacturer,
|
||||
description=slae_sh_device.description,
|
||||
)
|
||||
]
|
||||
|
||||
with patch("pyudev.Context"), patch(
|
||||
"pyudev.Monitor.filter_by", side_effect=ValueError
|
||||
), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch(
|
||||
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||
), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_config_flow:
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_config_flow.mock_calls) == 1
|
||||
assert mock_config_flow.mock_calls[0][1][0] == "test1"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="Only works on linux",
|
||||
|
||||
@@ -98,3 +98,33 @@ async def test_update(hass):
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state
|
||||
assert state.state == "1234"
|
||||
|
||||
|
||||
async def test_image_name_container(hass):
|
||||
"""Test the Version sensor with image name for container."""
|
||||
config = {
|
||||
"sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"}
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.version.sensor.HaVersion") as haversion:
|
||||
assert await async_setup_component(hass, "sensor", config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
constructor = haversion.call_args[1]
|
||||
assert constructor["source"] == "container"
|
||||
assert constructor["image"] == "qemux86-64-homeassistant"
|
||||
|
||||
|
||||
async def test_image_name_supervisor(hass):
|
||||
"""Test the Version sensor with image name for supervisor."""
|
||||
config = {
|
||||
"sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"}
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.version.sensor.HaVersion") as haversion:
|
||||
assert await async_setup_component(hass, "sensor", config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
constructor = haversion.call_args[1]
|
||||
assert constructor["source"] == "supervisor"
|
||||
assert constructor["image"] == "qemux86-64"
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.yeelight import (
|
||||
DOMAIN,
|
||||
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
||||
)
|
||||
from homeassistant.components.yeelight.config_flow import CannotConnect
|
||||
from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM
|
||||
@@ -28,6 +28,7 @@ from . import (
|
||||
CAPABILITIES,
|
||||
ID,
|
||||
IP_ADDRESS,
|
||||
MODEL,
|
||||
MODULE,
|
||||
MODULE_CONFIG_FLOW,
|
||||
NAME,
|
||||
@@ -87,7 +88,7 @@ async def test_discovery(hass: HomeAssistant):
|
||||
)
|
||||
assert result3["type"] == "create_entry"
|
||||
assert result3["title"] == UNIQUE_FRIENDLY_NAME
|
||||
assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS}
|
||||
assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS, CONF_MODEL: MODEL}
|
||||
await hass.async_block_till_done()
|
||||
mock_setup.assert_called_once()
|
||||
mock_setup_entry.assert_called_once()
|
||||
@@ -160,7 +161,11 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant):
|
||||
)
|
||||
assert result3["type"] == "create_entry"
|
||||
assert result3["title"] == UNIQUE_FRIENDLY_NAME
|
||||
assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS}
|
||||
assert result3["data"] == {
|
||||
CONF_ID: ID,
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_MODEL: MODEL,
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -300,7 +305,11 @@ async def test_manual(hass: HomeAssistant):
|
||||
await hass.async_block_till_done()
|
||||
assert result4["type"] == "create_entry"
|
||||
assert result4["title"] == "Color 0x15243f"
|
||||
assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"}
|
||||
assert result4["data"] == {
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_ID: "0x000000000015243f",
|
||||
CONF_MODEL: MODEL,
|
||||
}
|
||||
|
||||
# Duplicate
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -333,7 +342,7 @@ async def test_options(hass: HomeAssistant):
|
||||
|
||||
config = {
|
||||
CONF_NAME: NAME,
|
||||
CONF_MODEL: "",
|
||||
CONF_MODEL: MODEL,
|
||||
CONF_TRANSITION: DEFAULT_TRANSITION,
|
||||
CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
|
||||
CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
|
||||
@@ -383,7 +392,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant):
|
||||
result["flow_id"], {CONF_HOST: IP_ADDRESS}
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: None}
|
||||
assert result["data"] == {
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_ID: None,
|
||||
CONF_MODEL: MODEL_UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
async def test_discovered_by_homekit_and_dhcp(hass):
|
||||
@@ -480,7 +493,11 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data):
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"}
|
||||
assert result2["data"] == {
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_ID: "0x000000000015243f",
|
||||
CONF_MODEL: MODEL,
|
||||
}
|
||||
assert mock_async_setup.called
|
||||
assert mock_async_setup_entry.called
|
||||
|
||||
@@ -540,7 +557,11 @@ async def test_discovered_ssdp(hass):
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"}
|
||||
assert result2["data"] == {
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_ID: "0x000000000015243f",
|
||||
CONF_MODEL: MODEL,
|
||||
}
|
||||
assert mock_async_setup.called
|
||||
assert mock_async_setup_entry.called
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
|
||||
from yeelight import BulbException, BulbType
|
||||
|
||||
from homeassistant.components.yeelight import (
|
||||
CONF_MODEL,
|
||||
CONF_NIGHTLIGHT_SWITCH,
|
||||
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
||||
DATA_CONFIG_ENTRIES,
|
||||
@@ -35,6 +36,7 @@ from . import (
|
||||
FAIL_TO_BIND_IP,
|
||||
ID,
|
||||
IP_ADDRESS,
|
||||
MODEL,
|
||||
MODULE,
|
||||
SHORT_ID,
|
||||
_mocked_bulb,
|
||||
@@ -360,6 +362,7 @@ async def test_async_listen_error_late_discovery(hass, caplog):
|
||||
|
||||
assert "Failed to connect to bulb at" not in caplog.text
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert config_entry.options[CONF_MODEL] == MODEL
|
||||
|
||||
|
||||
async def test_async_listen_error_has_host_with_id(hass: HomeAssistant):
|
||||
|
||||
@@ -96,6 +96,7 @@ from homeassistant.util.color import (
|
||||
)
|
||||
|
||||
from . import (
|
||||
CAPABILITIES,
|
||||
ENTITY_LIGHT,
|
||||
ENTITY_NIGHTLIGHT,
|
||||
IP_ADDRESS,
|
||||
@@ -1122,3 +1123,94 @@ async def test_effects(hass: HomeAssistant):
|
||||
for name, target in effects.items():
|
||||
await _async_test_effect(name, target)
|
||||
await _async_test_effect("not_existed", called=False)
|
||||
|
||||
|
||||
async def test_state_fails_to_update_triggers_update(hass: HomeAssistant):
|
||||
"""Ensure we call async_get_properties if the turn on/off fails to update the state."""
|
||||
mocked_bulb = _mocked_bulb()
|
||||
properties = {**PROPERTIES}
|
||||
properties.pop("active_mode")
|
||||
properties["color_mode"] = "3" # HSV
|
||||
mocked_bulb.last_properties = properties
|
||||
mocked_bulb.bulb_type = BulbType.Color
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
# We use asyncio.create_task now to avoid
|
||||
# blocking starting so we need to block again
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mocked_bulb.last_properties["power"] = "off"
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mocked_bulb.async_turn_on.mock_calls) == 1
|
||||
assert len(mocked_bulb.async_get_properties.mock_calls) == 2
|
||||
|
||||
mocked_bulb.last_properties["power"] = "on"
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
SERVICE_TURN_OFF,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mocked_bulb.async_turn_off.mock_calls) == 1
|
||||
assert len(mocked_bulb.async_get_properties.mock_calls) == 3
|
||||
|
||||
# But if the state is correct no calls
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mocked_bulb.async_turn_on.mock_calls) == 1
|
||||
assert len(mocked_bulb.async_get_properties.mock_calls) == 3
|
||||
|
||||
|
||||
async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant):
|
||||
"""Test that main light on ambilights with the nightlight disabled shows the correct brightness."""
|
||||
mocked_bulb = _mocked_bulb()
|
||||
properties = {**PROPERTIES}
|
||||
capabilities = {**CAPABILITIES}
|
||||
capabilities["model"] = "ceiling10"
|
||||
properties["color_mode"] = "3" # HSV
|
||||
properties["bg_power"] = "off"
|
||||
properties["current_brightness"] = 0
|
||||
properties["bg_lmode"] = "2" # CT
|
||||
mocked_bulb.last_properties = properties
|
||||
mocked_bulb.bulb_type = BulbType.WhiteTempMood
|
||||
main_light_entity_id = "light.yeelight_ceiling10_0x15243f"
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False},
|
||||
options={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
with _patch_discovery(capabilities=capabilities), patch(
|
||||
f"{MODULE}.AsyncBulb", return_value=mocked_bulb
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
# We use asyncio.create_task now to avoid
|
||||
# blocking starting so we need to block again
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(main_light_entity_id)
|
||||
assert state.state == "on"
|
||||
# bg_power off should not set the brightness to 0
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 128
|
||||
|
||||
@@ -7,7 +7,7 @@ import serial.tools.list_ports
|
||||
import zigpy.config
|
||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||
|
||||
from homeassistant import setup
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.ssdp import (
|
||||
ATTR_SSDP_LOCATION,
|
||||
ATTR_UPNP_MANUFACTURER_URL,
|
||||
@@ -164,27 +164,17 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass):
|
||||
"zha", context={"source": SOURCE_USB}, data=discovery_info
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "not_zha_device"
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
|
||||
with patch("homeassistant.components.zha.async_setup_entry"):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||
async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass):
|
||||
"""Test usb flow -- reject the nortek zwave radio."""
|
||||
discovery_info = {
|
||||
"device": "/dev/null",
|
||||
"vid": "10C4",
|
||||
"pid": "8A2A",
|
||||
"serial_number": "612020FD",
|
||||
"description": "HubZ Smart Home Controller - HubZ Z-Wave Com Port",
|
||||
"manufacturer": "Silicon Labs",
|
||||
}
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"zha", context={"source": SOURCE_USB}, data=discovery_info
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "not_zha_device"
|
||||
assert result2["type"] == RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "usb_probe_failed"
|
||||
|
||||
|
||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||
@@ -281,6 +271,52 @@ async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass):
|
||||
assert result["reason"] == "not_zha_device"
|
||||
|
||||
|
||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||
async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass):
|
||||
"""Test usb flow -- deconz setup."""
|
||||
MockConfigEntry(domain="deconz", data={}).add_to_hass(hass)
|
||||
await hass.async_block_till_done()
|
||||
discovery_info = {
|
||||
"device": "/dev/ttyZIGBEE",
|
||||
"pid": "AAAA",
|
||||
"vid": "AAAA",
|
||||
"serial_number": "1234",
|
||||
"description": "zigbee radio",
|
||||
"manufacturer": "test",
|
||||
}
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"zha", context={"source": SOURCE_USB}, data=discovery_info
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "not_zha_device"
|
||||
|
||||
|
||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||
async def test_discovery_via_usb_deconz_ignored(detect_mock, hass):
|
||||
"""Test usb flow -- deconz ignored."""
|
||||
MockConfigEntry(
|
||||
domain="deconz", source=config_entries.SOURCE_IGNORE, data={}
|
||||
).add_to_hass(hass)
|
||||
await hass.async_block_till_done()
|
||||
discovery_info = {
|
||||
"device": "/dev/ttyZIGBEE",
|
||||
"pid": "AAAA",
|
||||
"vid": "AAAA",
|
||||
"serial_number": "1234",
|
||||
"description": "zigbee radio",
|
||||
"manufacturer": "test",
|
||||
}
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"zha", context={"source": SOURCE_USB}, data=discovery_info
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
|
||||
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||
async def test_discovery_already_setup(detect_mock, hass):
|
||||
|
||||
Reference in New Issue
Block a user