Compare commits

..

37 Commits

Author SHA1 Message Date
Paulus Schoutsen 6c36d5acaa Bumped version to 2022.9.0b5 2022-09-05 14:28:36 -04:00
Bram Kragten e8c4711d88 Update frontend to 20220905.0 (#77854) 2022-09-05 14:28:26 -04:00
J. Nick Koston bca9dc1f61 Bump govee-ble to 0.17.2 (#77849) 2022-09-05 14:28:25 -04:00
J. Nick Koston 4f8421617e Bump led-ble to 0.7.0 (#77845) 2022-09-05 14:28:24 -04:00
Erik Montnemery 40421b41f7 Add the hardware integration to default_config (#77840) 2022-09-05 14:28:24 -04:00
Jc2k b0ff4fc057 Less verbose error logs for bleak connection errors in ActiveBluetoothProcessorCoordinator (#77839)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-09-05 14:28:23 -04:00
Charles Garwood 605e350159 Add remoteAdminPasswordEnd to redacted keys in fully_kiosk diagnostics (#77837)
Add remoteAdminPasswordEnd to redacted keys in diagnostics
2022-09-05 14:28:22 -04:00
Artem Draft ad8cd9c957 Bump pybravia to 0.2.1 (#77832) 2022-09-05 14:28:21 -04:00
Raman Gupta e8ab4eef44 Fix device info for zwave_js device entities (#77821) 2022-09-05 14:28:21 -04:00
J. Nick Koston b1241bf0f2 Fix isy994 calling sync api in async context (#77812) 2022-09-05 14:28:20 -04:00
J. Nick Koston f3e811417f Prefilter noisy apple devices from bluetooth (#77808) 2022-09-05 14:28:19 -04:00
Ernst Klamer 1231ba4d03 Rename BThome to BTHome (#77807)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-09-05 14:28:19 -04:00
G Johansson e07554dc25 Bump yale_smart_alarm_client to 0.3.9 (#77797) 2022-09-05 14:28:18 -04:00
Robert Hillis 2fa517b81b Make Sonos typing more complete (#68072) 2022-09-05 14:28:17 -04:00
Paulus Schoutsen 0d042d496d Bumped version to 2022.9.0b4 2022-09-04 13:00:37 -04:00
G Johansson c8156d5de6 Bump pysensibo to 1.0.19 (#77790) 2022-09-04 13:00:28 -04:00
J. Nick Koston 9f06baa778 Bump led-ble to 0.6.0 (#77788)
* Bump ble-led to 0.6.0

Fixes reading the white channel on same devices

Changelog: https://github.com/Bluetooth-Devices/led-ble/compare/v0.5.4...v0.6.0

* Bump flux_led to 0.28.32

Changelog: https://github.com/Danielhiversen/flux_led/compare/0.28.31...0.28.32

Fixes white channel support for some more older protocols

* keep them in sync

* Update homeassistant/components/led_ble/manifest.json
2022-09-04 13:00:27 -04:00
J. Nick Koston 52abf0851b Bump flux_led to 0.28.32 (#77787) 2022-09-04 13:00:27 -04:00
Justin Vanderhooft da83ceca5b Tweak unique id formatting for Melnor Bluetooth switches (#77773) 2022-09-04 13:00:26 -04:00
Avi Miller f9b95cc4a4 Fix lifx service call interference (#77770)
* Fix #77735 by restoring the wait to let state settle

Signed-off-by: Avi Miller <me@dje.li>

* Skip the asyncio.sleep during testing

Signed-off-by: Avi Miller <me@dje.li>

* Patch out asyncio.sleep for lifx tests

Signed-off-by: Avi Miller <me@dje.li>

* Patch out a constant instead of overriding asyncio.sleep directly

Signed-off-by: Avi Miller <me@dje.li>

Signed-off-by: Avi Miller <me@dje.li>
2022-09-04 13:00:25 -04:00
Avi Miller f60ae40661 Rename the binary sensor to better reflect its purpose (#77711) 2022-09-04 13:00:25 -04:00
Avi Miller ea0b406692 Add binary sensor platform to LIFX integration (#77535)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-09-04 13:00:24 -04:00
Michael 9387449abf Replace archived sucks by py-sucks and bump to 0.9.8 for Ecovacs integration (#77768) 2022-09-04 12:58:19 -04:00
Matt Zimmerman 5f4013164c Update smarttub to 0.0.33 (#77766) 2022-09-04 12:58:18 -04:00
Raman Gupta 3856178dc0 Handle dead nodes in zwave_js update entity (#77763) 2022-09-04 12:58:17 -04:00
J. Nick Koston 32a9fba58e Increase default august timeout (#77762)
Fixes
```
2022-08-28 20:32:46.223 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/Users/bdraco/home-assistant/homeassistant/helpers/debounce.py", line 82, in async_call
    await task
  File "/Users/bdraco/home-assistant/homeassistant/components/august/activity.py", line 49, in _async_update_house_id
    await self._async_update_house_id(house_id)
  File "/Users/bdraco/home-assistant/homeassistant/components/august/activity.py", line 137, in _async_update_house_id
    activities = await self._api.async_get_house_activities(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/yalexs/api_async.py", line 96, in async_get_house_activities
    response = await self._async_dict_to_api(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/yalexs/api_async.py", line 294, in _async_dict_to_api
    response = await self._aiohttp_session.request(method, url, **api_dict)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/aiohttp/client.py", line 466, in _request
    with timer:
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/aiohttp/helpers.py", line 721, in __exit__
    raise asyncio.TimeoutError from None
asyncio.exceptions.TimeoutError
```
2022-09-04 12:58:17 -04:00
J. Nick Koston 9733887b6a Add BlueMaestro integration (#77758)
* Add BlueMaestro integration

* tests

* dc
2022-09-04 12:58:16 -04:00
Michael b215514c90 Fix upgrade api disabling during setup of Synology DSM (#77753) 2022-09-04 12:58:15 -04:00
Pete 0e930fd626 Fix setting and reading percentage for MIOT based fans (#77626) 2022-09-04 12:58:15 -04:00
Simon Hansen cd4c31bc79 Convert platform in iss integration (#77218)
* Hopefully fix everthing and be happy

* ...

* update coverage file

* Fix tests
2022-09-04 12:58:14 -04:00
starkillerOG bc04755d05 Register xiaomi_miio unload callbacks later in setup (#76714) 2022-09-04 12:58:13 -04:00
Paulus Schoutsen 041eaf27a9 Bumped version to 2022.9.0b3 2022-09-02 20:54:37 -04:00
Paulus Schoutsen d6a99da461 Bump frontend to 20220902.0 (#77734) 2022-09-02 20:54:30 -04:00
Raman Gupta 1d2439a6e5 Change zwave_js firmware update service API key (#77719)
* Change zwave_js firmware update service API key

* Update const.py
2022-09-02 20:54:29 -04:00
J. Nick Koston 6fff633325 Bump bluetooth-adapters to 3.3.4 (#77705) 2022-09-02 20:54:29 -04:00
Nathan Spencer 9652c0c326 Adjust litterrobot platform loading/unloading (#77682)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2022-09-02 20:54:28 -04:00
Christopher Bailey 36c1b9a419 Fix timezone edge cases for Unifi Protect media source (#77636)
* Fixes timezone edge cases for Unifi Protect media source

* linting
2022-09-02 20:54:27 -04:00
95 changed files with 1692 additions and 565 deletions
+2 -2
View File
@@ -587,7 +587,7 @@ omit =
homeassistant/components/iqvia/sensor.py
homeassistant/components/irish_rail_transport/sensor.py
homeassistant/components/iss/__init__.py
homeassistant/components/iss/binary_sensor.py
homeassistant/components/iss/sensor.py
homeassistant/components/isy994/__init__.py
homeassistant/components/isy994/binary_sensor.py
homeassistant/components/isy994/climate.py
@@ -1216,7 +1216,7 @@ omit =
homeassistant/components/switchbot/const.py
homeassistant/components/switchbot/entity.py
homeassistant/components/switchbot/cover.py
homeassistant/components/switchbot/light.py
homeassistant/components/switchbot/light.py
homeassistant/components/switchbot/sensor.py
homeassistant/components/switchbot/coordinator.py
homeassistant/components/switchmate/switch.py
+3 -1
View File
@@ -137,6 +137,8 @@ build.json @home-assistant/supervisor
/tests/components/blebox/ @bbx-a @riokuu
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/bluemaestro/ @bdraco
/tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core
/tests/components/blueprint/ @home-assistant/core
/homeassistant/components/bluesound/ @thrawnarn
@@ -275,7 +277,7 @@ build.json @home-assistant/supervisor
/tests/components/ecobee/ @marthoc
/homeassistant/components/econet/ @vangorra @w1ll1am23
/tests/components/econet/ @vangorra @w1ll1am23
/homeassistant/components/ecovacs/ @OverloadUT
/homeassistant/components/ecovacs/ @OverloadUT @mib1185
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/edl21/ @mtdcr
+1 -1
View File
@@ -4,7 +4,7 @@ from datetime import timedelta
from homeassistant.const import Platform
DEFAULT_TIMEOUT = 15
DEFAULT_TIMEOUT = 25
CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file"
CONF_LOGIN_METHOD = "login_method"
@@ -0,0 +1,49 @@
"""The BlueMaestro integration."""
from __future__ import annotations
import logging
from bluemaestro_ble import BlueMaestroBluetoothDeviceData
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BlueMaestro BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = BlueMaestroBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=data.update,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -0,0 +1,94 @@
"""Config flow for bluemaestro ble integration."""
from __future__ import annotations
from typing import Any
from bluemaestro_ble import BlueMaestroBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
class BlueMaestroConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for bluemaestro."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = DeviceData()
if not device.supported(discovery_info):
return self.async_abort(reason="not_supported")
self._discovery_info = discovery_info
self._discovered_device = device
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovered_device is not None
device = self._discovered_device
assert self._discovery_info is not None
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})
self._set_confirm_only()
placeholders = {"name": title}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="bluetooth_confirm", description_placeholders=placeholders
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step to pick discovered device."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
)
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)
@@ -0,0 +1,3 @@
"""Constants for the BlueMaestro integration."""
DOMAIN = "bluemaestro"
@@ -0,0 +1,31 @@
"""Support for BlueMaestro devices."""
from __future__ import annotations
from bluemaestro_ble import DeviceKey, SensorDeviceInfo
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothEntityKey,
)
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME
from homeassistant.helpers.entity import DeviceInfo
def device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
def sensor_device_info_to_hass(
sensor_device_info: SensorDeviceInfo,
) -> DeviceInfo:
"""Convert a bluemaestro device info to a sensor device info."""
hass_device_info = DeviceInfo({})
if sensor_device_info.name is not None:
hass_device_info[ATTR_NAME] = sensor_device_info.name
if sensor_device_info.manufacturer is not None:
hass_device_info[ATTR_MANUFACTURER] = sensor_device_info.manufacturer
if sensor_device_info.model is not None:
hass_device_info[ATTR_MODEL] = sensor_device_info.model
return hass_device_info
@@ -0,0 +1,16 @@
{
"domain": "bluemaestro",
"name": "BlueMaestro",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluemaestro",
"bluetooth": [
{
"manufacturer_id": 307,
"connectable": false
}
],
"requirements": ["bluemaestro-ble==0.2.0"],
"dependencies": ["bluetooth"],
"codeowners": ["@bdraco"],
"iot_class": "local_push"
}
@@ -0,0 +1,149 @@
"""Support for BlueMaestro sensors."""
from __future__ import annotations
from typing import Optional, Union
from bluemaestro_ble import (
SensorDeviceClass as BlueMaestroSensorDeviceClass,
SensorUpdate,
Units,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
PRESSURE_MBAR,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .device import device_key_to_bluetooth_entity_key, sensor_device_info_to_hass
SENSOR_DESCRIPTIONS = {
(BlueMaestroSensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.BATTERY}_{Units.PERCENTAGE}",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
(BlueMaestroSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
(
BlueMaestroSensorDeviceClass.SIGNAL_STRENGTH,
Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
(
BlueMaestroSensorDeviceClass.TEMPERATURE,
Units.TEMP_CELSIUS,
): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
(
BlueMaestroSensorDeviceClass.DEW_POINT,
Units.TEMP_CELSIUS,
): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.DEW_POINT}_{Units.TEMP_CELSIUS}",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
(
BlueMaestroSensorDeviceClass.PRESSURE,
Units.PRESSURE_MBAR,
): SensorEntityDescription(
key=f"{BlueMaestroSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=PRESSURE_MBAR,
state_class=SensorStateClass.MEASUREMENT,
),
}
def sensor_update_to_bluetooth_data_update(
sensor_update: SensorUpdate,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
(description.device_class, description.native_unit_of_measurement)
]
for device_key, description in sensor_update.entity_descriptions.items()
if description.device_class and description.native_unit_of_measurement
},
entity_data={
device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.entity_values.items()
},
entity_names={
device_key_to_bluetooth_entity_key(device_key): sensor_values.name
for device_key, sensor_values in sensor_update.entity_values.items()
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BlueMaestro BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
BlueMaestroBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class BlueMaestroBluetoothSensorEntity(
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
],
SensorEntity,
):
"""Representation of a BlueMaestro sensor."""
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)
@@ -0,0 +1,22 @@
{
"config": {
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
"step": {
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:component::bluetooth::config::step::user::data::address%]"
}
},
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
}
},
"abort": {
"not_supported": "Device not supported",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"no_devices_found": "No devices found on the network",
"not_supported": "Device not supported"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to setup {name}?"
},
"user": {
"data": {
"address": "Device"
},
"description": "Choose a device to setup"
}
}
}
}
@@ -6,6 +6,8 @@ import logging
import time
from typing import Any, Generic, TypeVar
from bleak import BleakError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
@@ -109,6 +111,13 @@ class ActiveBluetoothProcessorCoordinator(
try:
update = await self._async_poll_data(self._last_service_info)
except BleakError as exc:
if self.last_poll_successful:
self.logger.error(
"%s: Bluetooth error whilst polling: %s", self.address, str(exc)
)
self.last_poll_successful = False
return
except Exception: # pylint: disable=broad-except
if self.last_poll_successful:
self.logger.exception("%s: Failure while polling", self.address)
+17 -1
View File
@@ -54,6 +54,10 @@ if TYPE_CHECKING:
FILTER_UUIDS: Final = "UUIDs"
APPLE_MFR_ID: Final = 76
APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller
APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker
APPLE_START_BYTES_WANTED: Final = {APPLE_DEVICE_ID_START_BYTE, APPLE_HOMEKIT_START_BYTE}
RSSI_SWITCH_THRESHOLD = 6
@@ -290,6 +294,19 @@ class BluetoothManager:
than the source from the history or the timestamp
in the history is older than 180s
"""
# Pre-filter noisy apple devices as they can account for 20-35% of the
# traffic on a typical network.
advertisement_data = service_info.advertisement
manufacturer_data = advertisement_data.manufacturer_data
if (
len(manufacturer_data) == 1
and (apple_data := manufacturer_data.get(APPLE_MFR_ID))
and apple_data[0] not in APPLE_START_BYTES_WANTED
and not advertisement_data.service_data
):
return
device = service_info.device
connectable = service_info.connectable
address = device.address
@@ -299,7 +316,6 @@ class BluetoothManager:
return
self._history[address] = service_info
advertisement_data = service_info.advertisement
source = service_info.source
if connectable:
@@ -6,7 +6,7 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.16.0",
"bluetooth-adapters==0.3.3",
"bluetooth-adapters==0.3.4",
"bluetooth-auto-recovery==0.3.0"
],
"codeowners": ["@bdraco"],
@@ -2,7 +2,7 @@
"domain": "braviatv",
"name": "Sony Bravia TV",
"documentation": "https://www.home-assistant.io/integrations/braviatv",
"requirements": ["pybravia==0.2.0"],
"requirements": ["pybravia==0.2.1"],
"codeowners": ["@bieniu", "@Drafteed"],
"config_flow": true,
"iot_class": "local_polling",
+5 -5
View File
@@ -1,9 +1,9 @@
"""The BThome Bluetooth integration."""
"""The BTHome Bluetooth integration."""
from __future__ import annotations
import logging
from bthome_ble import BThomeBluetoothDeviceData, SensorUpdate
from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate
from bthome_ble.parser import EncryptionScheme
from homeassistant.components.bluetooth import (
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
def process_service_info(
hass: HomeAssistant,
entry: ConfigEntry,
data: BThomeBluetoothDeviceData,
data: BTHomeBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
@@ -40,14 +40,14 @@ def process_service_info(
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BThome Bluetooth from a config entry."""
"""Set up BTHome Bluetooth from a config entry."""
address = entry.unique_id
assert address is not None
kwargs = {}
if bindkey := entry.data.get("bindkey"):
kwargs["bindkey"] = bytes.fromhex(bindkey)
data = BThomeBluetoothDeviceData(**kwargs)
data = BTHomeBluetoothDeviceData(**kwargs)
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
@@ -1,11 +1,11 @@
"""Config flow for BThome Bluetooth integration."""
"""Config flow for BTHome Bluetooth integration."""
from __future__ import annotations
from collections.abc import Mapping
import dataclasses
from typing import Any
from bthome_ble import BThomeBluetoothDeviceData as DeviceData
from bthome_ble import BTHomeBluetoothDeviceData as DeviceData
from bthome_ble.parser import EncryptionScheme
import voluptuous as vol
@@ -34,8 +34,8 @@ def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str:
return device.title or device.get_device_name() or discovery_info.name
class BThomeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BThome Bluetooth."""
class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BTHome Bluetooth."""
VERSION = 1
@@ -68,7 +68,7 @@ class BThomeConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_get_encryption_key(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Enter a bindkey for an encrypted BThome device."""
"""Enter a bindkey for an encrypted BTHome device."""
assert self._discovery_info
assert self._discovered_device
+1 -1
View File
@@ -1,3 +1,3 @@
"""Constants for the BThome Bluetooth integration."""
"""Constants for the BTHome Bluetooth integration."""
DOMAIN = "bthome"
+1 -1
View File
@@ -1,4 +1,4 @@
"""Support for BThome Bluetooth devices."""
"""Support for BTHome Bluetooth devices."""
from __future__ import annotations
from bthome_ble import DeviceKey, SensorDeviceInfo
@@ -1,6 +1,6 @@
{
"domain": "bthome",
"name": "BThome",
"name": "BTHome",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bthome",
"bluetooth": [
@@ -13,7 +13,7 @@
"service_data_uuid": "0000181e-0000-1000-8000-00805f9b34fb"
}
],
"requirements": ["bthome-ble==0.5.2"],
"requirements": ["bthome-ble==1.0.0"],
"dependencies": ["bluetooth"],
"codeowners": ["@Ernst79"],
"iot_class": "local_push"
+5 -5
View File
@@ -1,4 +1,4 @@
"""Support for BThome sensors."""
"""Support for BTHome sensors."""
from __future__ import annotations
from typing import Optional, Union
@@ -202,26 +202,26 @@ async def async_setup_entry(
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BThome BLE sensors."""
"""Set up the BTHome BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
BThomeBluetoothSensorEntity, async_add_entities
BTHomeBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class BThomeBluetoothSensorEntity(
class BTHomeBluetoothSensorEntity(
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
],
SensorEntity,
):
"""Representation of a BThome BLE sensor."""
"""Representation of a BTHome BLE sensor."""
@property
def native_value(self) -> int | float | None:
@@ -11,8 +11,9 @@
"dhcp",
"energy",
"frontend",
"homeassistant_alerts",
"hardware",
"history",
"homeassistant_alerts",
"input_boolean",
"input_button",
"input_datetime",
@@ -2,8 +2,8 @@
"domain": "ecovacs",
"name": "Ecovacs",
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"requirements": ["sucks==0.9.4"],
"codeowners": ["@OverloadUT"],
"requirements": ["py-sucks==0.9.8"],
"codeowners": ["@OverloadUT", "@mib1185"],
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks"]
}
@@ -4,7 +4,7 @@
"config_flow": true,
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/flux_led",
"requirements": ["flux_led==0.28.31"],
"requirements": ["flux_led==0.28.32"],
"quality_scale": "platinum",
"codeowners": ["@icemanch", "@bdraco"],
"iot_class": "local_push",
@@ -2,7 +2,7 @@
"domain": "frontend",
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": ["home-assistant-frontend==20220901.0"],
"requirements": ["home-assistant-frontend==20220905.0"],
"dependencies": [
"api",
"auth",
@@ -51,6 +51,7 @@ SETTINGS_TO_REDACT = {
"sebExamKey",
"sebConfigKey",
"kioskPinEnc",
"remoteAdminPasswordEnc",
}
@@ -53,7 +53,7 @@
"connectable": false
}
],
"requirements": ["govee-ble==0.17.1"],
"requirements": ["govee-ble==0.17.2"],
"dependencies": ["bluetooth"],
"codeowners": ["@bdraco"],
"iot_class": "local_push"
+4 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
import logging
import pyiss
@@ -18,7 +18,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR]
PLATFORMS = [Platform.SENSOR]
@dataclass
@@ -27,31 +27,25 @@ class IssData:
number_of_people_in_space: int
current_location: dict[str, str]
is_above: bool
next_rise: datetime
def update(iss: pyiss.ISS, latitude: float, longitude: float) -> IssData:
def update(iss: pyiss.ISS) -> IssData:
"""Retrieve data from the pyiss API."""
return IssData(
number_of_people_in_space=iss.number_of_people_in_space(),
current_location=iss.current_location(),
is_above=iss.is_ISS_above(latitude, longitude),
next_rise=iss.next_rise(latitude, longitude),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
hass.data.setdefault(DOMAIN, {})
latitude = hass.config.latitude
longitude = hass.config.longitude
iss = pyiss.ISS()
async def async_update() -> IssData:
try:
return await hass.async_add_executor_job(update, iss, latitude, longitude)
return await hass.async_add_executor_job(update, iss)
except (HTTPError, requests.exceptions.ConnectionError) as ex:
raise UpdateFailed("Unable to retrieve data") from ex
+2 -5
View File
@@ -7,9 +7,10 @@ from homeassistant.const import CONF_NAME, CONF_SHOW_ON_MAP
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .binary_sensor import DEFAULT_NAME
from .const import DOMAIN
DEFAULT_NAME = "ISS"
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for iss component."""
@@ -30,10 +31,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
# Check if location have been defined.
if not self.hass.config.latitude and not self.hass.config.longitude:
return self.async_abort(reason="latitude_longitude_not_defined")
if user_input is not None:
return self.async_create_entry(
title=user_input.get(CONF_NAME, DEFAULT_NAME),
@@ -1,10 +1,10 @@
"""Support for iss binary sensor."""
"""Support for iss sensor."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
from homeassistant.core import HomeAssistant
@@ -19,12 +19,6 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
ATTR_ISS_NEXT_RISE = "next_rise"
ATTR_ISS_NUMBER_PEOPLE_SPACE = "number_of_people_in_space"
DEFAULT_NAME = "ISS"
DEFAULT_DEVICE_CLASS = "visible"
async def async_setup_entry(
hass: HomeAssistant,
@@ -37,15 +31,11 @@ async def async_setup_entry(
name = entry.title
show_on_map = entry.options.get(CONF_SHOW_ON_MAP, False)
async_add_entities([IssBinarySensor(coordinator, name, show_on_map)])
async_add_entities([IssSensor(coordinator, name, show_on_map)])
class IssBinarySensor(
CoordinatorEntity[DataUpdateCoordinator[IssData]], BinarySensorEntity
):
"""Implementation of the ISS binary sensor."""
_attr_device_class = DEFAULT_DEVICE_CLASS
class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity):
"""Implementation of the ISS sensor."""
def __init__(
self, coordinator: DataUpdateCoordinator[IssData], name: str, show: bool
@@ -57,17 +47,14 @@ class IssBinarySensor(
self._show_on_map = show
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.coordinator.data.is_above is True
def native_value(self) -> int:
"""Return number of people in space."""
return self.coordinator.data.number_of_people_in_space
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attrs = {
ATTR_ISS_NUMBER_PEOPLE_SPACE: self.coordinator.data.number_of_people_in_space,
ATTR_ISS_NEXT_RISE: self.coordinator.data.next_rise,
}
attrs = {}
if self._show_on_map:
attrs[ATTR_LONGITUDE] = self.coordinator.data.current_location.get(
"longitude"
+1 -1
View File
@@ -75,7 +75,7 @@ class ISYEntity(Entity):
# New state attributes may be available, update the state.
self.async_write_ha_state()
self.hass.bus.fire("isy994_control", event_data)
self.hass.bus.async_fire("isy994_control", event_data)
@property
def device_info(self) -> DeviceInfo | None:
@@ -3,7 +3,7 @@
"name": "LED BLE",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ble_ble",
"requirements": ["led-ble==0.5.4"],
"requirements": ["led-ble==0.7.0"],
"dependencies": ["bluetooth"],
"codeowners": ["@bdraco"],
"bluetooth": [
@@ -11,7 +11,10 @@
{ "local_name": "BLE-LED*" },
{ "local_name": "LEDBLE*" },
{ "local_name": "Triones*" },
{ "local_name": "LEDBlue*" }
{ "local_name": "LEDBlue*" },
{ "local_name": "Dream~*" },
{ "local_name": "QHM-*" },
{ "local_name": "AP-*" }
],
"iot_class": "local_polling"
}
+1 -1
View File
@@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.All(
)
PLATFORMS = [Platform.BUTTON, Platform.LIGHT]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
DISCOVERY_INTERVAL = timedelta(minutes=15)
MIGRATION_INTERVAL = timedelta(minutes=5)
@@ -0,0 +1,70 @@
"""Binary sensor entities for LIFX integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, HEV_CYCLE_STATE
from .coordinator import LIFXUpdateCoordinator
from .entity import LIFXEntity
from .util import lifx_features
HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription(
key=HEV_CYCLE_STATE,
name="Clean Cycle",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.RUNNING,
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
if lifx_features(coordinator.device)["hev"]:
async_add_entities(
[
LIFXHevCycleBinarySensorEntity(
coordinator=coordinator, description=HEV_CYCLE_STATE_SENSOR
)
]
)
class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity):
"""LIFX HEV cycle state binary sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: LIFXUpdateCoordinator,
description: BinarySensorEntityDescription,
) -> None:
"""Initialise the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_name = description.name
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._async_update_attrs()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_attrs()
super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Handle coordinator updates."""
self._attr_is_on = self.coordinator.async_get_hev_cycle_state()
+10 -1
View File
@@ -29,6 +29,15 @@ IDENTIFY_WAVEFORM = {
IDENTIFY = "identify"
RESTART = "restart"
ATTR_DURATION = "duration"
ATTR_INDICATION = "indication"
ATTR_INFRARED = "infrared"
ATTR_POWER = "power"
ATTR_REMAINING = "remaining"
ATTR_ZONES = "zones"
HEV_CYCLE_STATE = "hev_cycle_state"
DATA_LIFX_MANAGER = "lifx_manager"
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger(__package__)
+25 -13
View File
@@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
_LOGGER,
ATTR_REMAINING,
IDENTIFY_WAVEFORM,
MESSAGE_RETRIES,
MESSAGE_TIMEOUT,
@@ -24,6 +25,7 @@ from .const import (
from .util import async_execute_lifx, get_real_mac_addr, lifx_features
REQUEST_REFRESH_DELAY = 0.35
LIFX_IDENTIFY_DELAY = 3.0
class LIFXUpdateCoordinator(DataUpdateCoordinator):
@@ -91,7 +93,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
# Turn the bulb on first, flash for 3 seconds, then turn off
await self.async_set_power(state=True, duration=1)
await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM)
await asyncio.sleep(3)
await asyncio.sleep(LIFX_IDENTIFY_DELAY)
await self.async_set_power(state=False, duration=1)
async def _async_update_data(self) -> None:
@@ -101,26 +103,25 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
self.device.get_hostfirmware()
if self.device.product is None:
self.device.get_version()
try:
response = await async_execute_lifx(self.device.get_color)
except asyncio.TimeoutError as ex:
raise UpdateFailed(
f"Failed to fetch state from device: {self.device.ip_addr}"
) from ex
response = await async_execute_lifx(self.device.get_color)
if self.device.product is None:
raise UpdateFailed(
f"Failed to fetch get version from device: {self.device.ip_addr}"
)
# device.mac_addr is not the mac_address, its the serial number
if self.device.mac_addr == TARGET_ANY:
self.device.mac_addr = response.target_addr
if lifx_features(self.device)["multizone"]:
try:
await self.async_update_color_zones()
except asyncio.TimeoutError as ex:
raise UpdateFailed(
f"Failed to fetch zones from device: {self.device.ip_addr}"
) from ex
await self.async_update_color_zones()
if lifx_features(self.device)["hev"]:
if self.device.hev_cycle_configuration is None:
self.device.get_hev_configuration()
await self.async_get_hev_cycle()
async def async_update_color_zones(self) -> None:
"""Get updated color information for each zone."""
@@ -138,6 +139,17 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
if zone == top - 1:
zone -= 1
def async_get_hev_cycle_state(self) -> bool | None:
"""Return the current HEV cycle state."""
if self.device.hev_cycle is None:
return None
return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0)
async def async_get_hev_cycle(self) -> None:
"""Update the HEV cycle status from a LIFX Clean bulb."""
if lifx_features(self.device)["hev"]:
await async_execute_lifx(self.device.get_hev_cycle)
async def async_set_waveform_optional(
self, value: dict[str, Any], rapid: bool = False
) -> None:
+6 -9
View File
@@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
import homeassistant.util.color as color_util
from .const import DATA_LIFX_MANAGER, DOMAIN
from .const import ATTR_INFRARED, ATTR_POWER, ATTR_ZONES, DATA_LIFX_MANAGER, DOMAIN
from .coordinator import LIFXUpdateCoordinator
from .entity import LIFXEntity
from .manager import (
@@ -39,13 +39,7 @@ from .manager import (
)
from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk
SERVICE_LIFX_SET_STATE = "set_state"
COLOR_ZONE_POPULATE_DELAY = 0.3
ATTR_INFRARED = "infrared"
ATTR_ZONES = "zones"
ATTR_POWER = "power"
LIFX_STATE_SETTLE_DELAY = 0.3
SERVICE_LIFX_SET_STATE = "set_state"
@@ -237,6 +231,9 @@ class LIFXLight(LIFXEntity, LightEntity):
if power_off:
await self.set_power(False, duration=fade)
# Avoid state ping-pong by holding off updates as the state settles
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
# Update when the transition starts and ends
await self.update_during_transition(fade)
@@ -344,7 +341,7 @@ class LIFXStrip(LIFXColor):
# Zone brightness is not reported when powered off
if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None:
await self.set_power(True)
await asyncio.sleep(COLOR_ZONE_POPULATE_DELAY)
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
await self.update_color_zones()
await self.set_power(False)
@@ -1,7 +1,7 @@
"""The Litter-Robot integration."""
from __future__ import annotations
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@@ -10,65 +10,48 @@ from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .hub import LitterRobotHub
PLATFORMS = [
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
]
PLATFORMS_BY_TYPE = {
LitterRobot: (
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
),
LitterRobot3: (
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
),
LitterRobot4: (
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
),
FeederRobot: (
Platform.BUTTON,
Robot: (
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
),
LitterRobot: (Platform.VACUUM,),
LitterRobot3: (Platform.BUTTON,),
FeederRobot: (Platform.BUTTON,),
}
def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]:
"""Get platforms for robots."""
return {
platform
for robot in robots
for robot_type, platforms in PLATFORMS_BY_TYPE.items()
if isinstance(robot, robot_type)
for platform in platforms
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Litter-Robot from a config entry."""
hass.data.setdefault(DOMAIN, {})
hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data)
await hub.login(load_robots=True)
platforms: set[str] = set()
for robot in hub.account.robots:
platforms.update(PLATFORMS_BY_TYPE[type(robot)])
if platforms:
if platforms := get_platforms_for_robots(hub.account.robots):
await hass.config_entries.async_forward_entry_setups(entry, platforms)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id]
await hub.account.disconnect()
platforms = get_platforms_for_robots(hub.account.robots)
unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
+1 -4
View File
@@ -48,10 +48,7 @@ class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity):
super().__init__(coordinator)
self._valve_index = valve_index
self._attr_unique_id = (
f"switch-{self._attr_unique_id}-zone{self._valve().id}-manual"
)
self._attr_unique_id = f"{self._attr_unique_id}-zone{self._valve().id}-manual"
self._attr_name = f"{self._device.name} Zone {self._valve().id+1}"
@property
@@ -2,7 +2,7 @@
"domain": "sensibo",
"name": "Sensibo",
"documentation": "https://www.home-assistant.io/integrations/sensibo",
"requirements": ["pysensibo==1.0.18"],
"requirements": ["pysensibo==1.0.19"],
"config_flow": true,
"codeowners": ["@andrey-git", "@gjohansson-ST"],
"iot_class": "cloud_polling",
@@ -5,7 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"dependencies": [],
"codeowners": ["@mdz"],
"requirements": ["python-smarttub==0.0.32"],
"requirements": ["python-smarttub==0.0.33"],
"quality_scale": "platinum",
"iot_class": "cloud_polling",
"loggers": ["smarttub"]
+24 -14
View File
@@ -8,6 +8,7 @@ import datetime
from functools import partial
import logging
import socket
from typing import TYPE_CHECKING, Any, Optional, cast
from urllib.parse import urlparse
from soco import events_asyncio
@@ -21,7 +22,7 @@ from homeassistant.components import ssdp
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.event import async_track_time_interval, call_later
@@ -93,7 +94,7 @@ class SonosData:
self.favorites: dict[str, SonosFavorites] = {}
self.alarms: dict[str, SonosAlarms] = {}
self.topology_condition = asyncio.Condition()
self.hosts_heartbeat = None
self.hosts_heartbeat: CALLBACK_TYPE | None = None
self.discovery_known: set[str] = set()
self.boot_counts: dict[str, int] = {}
self.mdns_names: dict[str, str] = {}
@@ -168,10 +169,10 @@ class SonosDiscoveryManager:
self.data = data
self.hosts = set(hosts)
self.discovery_lock = asyncio.Lock()
self._known_invisible = set()
self._known_invisible: set[SoCo] = set()
self._manual_config_required = bool(hosts)
async def async_shutdown(self):
async def async_shutdown(self) -> None:
"""Stop all running tasks."""
await self._async_stop_event_listener()
self._stop_manual_heartbeat()
@@ -236,6 +237,8 @@ class SonosDiscoveryManager:
(SonosAlarms, self.data.alarms),
(SonosFavorites, self.data.favorites),
):
if TYPE_CHECKING:
coord_dict = cast(dict[str, Any], coord_dict)
if soco.household_id not in coord_dict:
new_coordinator = coordinator(self.hass, soco.household_id)
new_coordinator.setup(soco)
@@ -298,7 +301,7 @@ class SonosDiscoveryManager:
)
async def _async_handle_discovery_message(
self, uid: str, discovered_ip: str, boot_seqnum: int
self, uid: str, discovered_ip: str, boot_seqnum: int | None
) -> None:
"""Handle discovered player creation and activity."""
async with self.discovery_lock:
@@ -338,22 +341,27 @@ class SonosDiscoveryManager:
async_dispatcher_send(self.hass, f"{SONOS_VANISHED}-{uid}", reason)
return
discovered_ip = urlparse(info.ssdp_location).hostname
boot_seqnum = info.ssdp_headers.get("X-RINCON-BOOTSEQ")
self.async_discovered_player(
"SSDP",
info,
discovered_ip,
cast(str, urlparse(info.ssdp_location).hostname),
uid,
boot_seqnum,
info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME),
info.ssdp_headers.get("X-RINCON-BOOTSEQ"),
cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)),
None,
)
@callback
def async_discovered_player(
self, source, info, discovered_ip, uid, boot_seqnum, model, mdns_name
):
self,
source: str,
info: ssdp.SsdpServiceInfo,
discovered_ip: str,
uid: str,
boot_seqnum: str | int | None,
model: str,
mdns_name: str | None,
) -> None:
"""Handle discovery via ssdp or zeroconf."""
if self._manual_config_required:
_LOGGER.warning(
@@ -376,10 +384,12 @@ class SonosDiscoveryManager:
_LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info)
self.data.discovery_known.add(uid)
asyncio.create_task(
self._async_handle_discovery_message(uid, discovered_ip, boot_seqnum)
self._async_handle_discovery_message(
uid, discovered_ip, cast(Optional[int], boot_seqnum)
)
)
async def setup_platforms_and_discovery(self):
async def setup_platforms_and_discovery(self) -> None:
"""Set up platforms and discovery."""
await self.hass.config_entries.async_forward_entry_setups(self.entry, PLATFORMS)
self.entry.async_on_unload(
@@ -109,6 +109,6 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity):
self.speaker.mic_enabled = self.soco.mic_enabled
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
return self.speaker.mic_enabled
@@ -47,11 +47,11 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
payload = {"current_timestamp": time.monotonic()}
payload: dict[str, Any] = {"current_timestamp": time.monotonic()}
for section in ("discovered", "discovery_known"):
payload[section] = {}
data = getattr(hass.data[DATA_SONOS], section)
data: set[Any] | dict[str, Any] = getattr(hass.data[DATA_SONOS], section)
if isinstance(data, set):
payload[section] = data
continue
@@ -60,7 +60,6 @@ async def async_get_config_entry_diagnostics(
payload[section][key] = await async_generate_speaker_info(hass, value)
else:
payload[section][key] = value
return payload
@@ -85,12 +84,12 @@ async def async_generate_media_info(
hass: HomeAssistant, speaker: SonosSpeaker
) -> dict[str, Any]:
"""Generate a diagnostic payload for current media metadata."""
payload = {}
payload: dict[str, Any] = {}
for attrib in MEDIA_DIAGNOSTIC_ATTRIBUTES:
payload[attrib] = getattr(speaker.media, attrib)
def poll_current_track_info():
def poll_current_track_info() -> dict[str, Any] | str:
try:
return speaker.soco.avTransport.GetPositionInfo(
[("InstanceID", 0), ("Channel", "Master")],
@@ -110,9 +109,11 @@ async def async_generate_speaker_info(
hass: HomeAssistant, speaker: SonosSpeaker
) -> dict[str, Any]:
"""Generate the diagnostic payload for a specific speaker."""
payload = {}
payload: dict[str, Any] = {}
def get_contents(item):
def get_contents(
item: int | float | str | dict[str, Any]
) -> int | float | str | dict[str, Any]:
if isinstance(item, (int, float, str)):
return item
if isinstance(item, dict):
@@ -20,13 +20,14 @@ _LOGGER = logging.getLogger(__name__)
class SonosHouseholdCoordinator:
"""Base class for Sonos household-level storage."""
cache_update_lock: asyncio.Lock
def __init__(self, hass: HomeAssistant, household_id: str) -> None:
"""Initialize the data."""
self.hass = hass
self.household_id = household_id
self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None
self.last_processed_event_id: int | None = None
self.cache_update_lock: asyncio.Lock | None = None
def setup(self, soco: SoCo) -> None:
"""Set up the SonosAlarm instance."""
+1 -4
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import datetime
import logging
from typing import Any
from soco.core import (
@@ -43,8 +42,6 @@ UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
DURATION_SECONDS = "duration_in_s"
POSITION_SECONDS = "position_in_s"
_LOGGER = logging.getLogger(__name__)
def _timespan_secs(timespan: str | None) -> None | float:
"""Parse a time-span into number of seconds."""
@@ -106,7 +103,7 @@ class SonosMedia:
@soco_error()
def poll_track_info(self) -> dict[str, Any]:
"""Poll the speaker for current track info, add converted position values, and return."""
track_info = self.soco.get_current_track_info()
track_info: dict[str, Any] = self.soco.get_current_track_info()
track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration"))
track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position"))
return track_info
+36 -23
View File
@@ -5,8 +5,13 @@ from collections.abc import Callable
from contextlib import suppress
from functools import partial
import logging
from typing import cast
from urllib.parse import quote_plus, unquote
from soco.data_structures import DidlFavorite, DidlObject
from soco.ms_data_structures import MusicServiceItem
from soco.music_library import MusicLibrary
from homeassistant.components import media_source, plex, spotify
from homeassistant.components.media_player import BrowseMedia
from homeassistant.components.media_player.const import (
@@ -50,12 +55,12 @@ def get_thumbnail_url_full(
) -> str | None:
"""Get thumbnail URL."""
if is_internal:
item = get_media( # type: ignore[no-untyped-call]
item = get_media(
media.library,
media_content_id,
media_content_type,
)
return getattr(item, "album_art_uri", None) # type: ignore[no-any-return]
return getattr(item, "album_art_uri", None)
return get_browse_image_url(
media_content_type,
@@ -64,19 +69,19 @@ def get_thumbnail_url_full(
)
def media_source_filter(item: BrowseMedia):
def media_source_filter(item: BrowseMedia) -> bool:
"""Filter media sources."""
return item.media_content_type.startswith("audio/")
async def async_browse_media(
hass,
hass: HomeAssistant,
speaker: SonosSpeaker,
media: SonosMedia,
get_browse_image_url: GetBrowseImageUrlType,
media_content_id: str | None,
media_content_type: str | None,
):
) -> BrowseMedia:
"""Browse media."""
if media_content_id is None:
@@ -86,6 +91,7 @@ async def async_browse_media(
media,
get_browse_image_url,
)
assert media_content_type is not None
if media_source.is_media_source_id(media_content_id):
return await media_source.async_browse_media(
@@ -150,7 +156,9 @@ async def async_browse_media(
return response
def build_item_response(media_library, payload, get_thumbnail_url=None):
def build_item_response(
media_library: MusicLibrary, payload: dict[str, str], get_thumbnail_url=None
) -> BrowseMedia | None:
"""Create response payload for the provided media query."""
if payload["search_type"] == MEDIA_TYPE_ALBUM and payload["idstring"].startswith(
("A:GENRE", "A:COMPOSER")
@@ -166,7 +174,7 @@ def build_item_response(media_library, payload, get_thumbnail_url=None):
"Unknown media type received when building item response: %s",
payload["search_type"],
)
return
return None
media = media_library.browse_by_idstring(
search_type,
@@ -176,7 +184,7 @@ def build_item_response(media_library, payload, get_thumbnail_url=None):
)
if media is None:
return
return None
thumbnail = None
title = None
@@ -222,7 +230,7 @@ def build_item_response(media_library, payload, get_thumbnail_url=None):
)
def item_payload(item, get_thumbnail_url=None):
def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia:
"""
Create response payload for a single media item.
@@ -256,9 +264,9 @@ async def root_payload(
speaker: SonosSpeaker,
media: SonosMedia,
get_browse_image_url: GetBrowseImageUrlType,
):
) -> BrowseMedia:
"""Return root payload for Sonos."""
children = []
children: list[BrowseMedia] = []
if speaker.favorites:
children.append(
@@ -303,14 +311,15 @@ async def root_payload(
if "spotify" in hass.config.components:
result = await spotify.async_browse_media(hass, None, None)
children.extend(result.children)
if result.children:
children.extend(result.children)
try:
item = await media_source.async_browse_media(
hass, None, content_filter=media_source_filter
)
# If domain is None, it's overview of available sources
if item.domain is None:
if item.domain is None and item.children is not None:
children.extend(item.children)
else:
children.append(item)
@@ -338,7 +347,7 @@ async def root_payload(
)
def library_payload(media_library, get_thumbnail_url=None):
def library_payload(media_library: MusicLibrary, get_thumbnail_url=None) -> BrowseMedia:
"""
Create response payload to describe contents of a specific library.
@@ -360,7 +369,7 @@ def library_payload(media_library, get_thumbnail_url=None):
)
def favorites_payload(favorites):
def favorites_payload(favorites: list[DidlFavorite]) -> BrowseMedia:
"""
Create response payload to describe contents of a specific library.
@@ -398,7 +407,9 @@ def favorites_payload(favorites):
)
def favorites_folder_payload(favorites, media_content_id):
def favorites_folder_payload(
favorites: list[DidlFavorite], media_content_id: str
) -> BrowseMedia:
"""Create response payload to describe all items of a type of favorite.
Used by async_browse_media.
@@ -432,7 +443,7 @@ def favorites_folder_payload(favorites, media_content_id):
)
def get_media_type(item):
def get_media_type(item: DidlObject) -> str:
"""Extract media type of item."""
if item.item_class == "object.item.audioItem.musicTrack":
return SONOS_TRACKS
@@ -450,7 +461,7 @@ def get_media_type(item):
return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class)
def can_play(item):
def can_play(item: DidlObject) -> bool:
"""
Test if playable.
@@ -459,7 +470,7 @@ def can_play(item):
return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES
def can_expand(item):
def can_expand(item: DidlObject) -> bool:
"""
Test if expandable.
@@ -474,14 +485,16 @@ def can_expand(item):
return SONOS_TYPES_MAPPING.get(item.item_id) in EXPANDABLE_MEDIA_TYPES
def get_content_id(item):
def get_content_id(item: DidlObject) -> str:
"""Extract content id or uri."""
if item.item_class == "object.item.audioItem.musicTrack":
return item.get_uri()
return item.item_id
return cast(str, item.get_uri())
return cast(str, item.item_id)
def get_media(media_library, item_id, search_type):
def get_media(
media_library: MusicLibrary, item_id: str, search_type: str
) -> MusicServiceItem:
"""Fetch media/album."""
search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type)
+37 -39
View File
@@ -130,11 +130,11 @@ async def async_setup_entry(
if service_call.service == SERVICE_SNAPSHOT:
await SonosSpeaker.snapshot_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type]
hass, speakers, service_call.data[ATTR_WITH_GROUP]
)
elif service_call.service == SERVICE_RESTORE:
await SonosSpeaker.restore_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type]
hass, speakers, service_call.data[ATTR_WITH_GROUP]
)
config_entry.async_on_unload(
@@ -153,7 +153,7 @@ async def async_setup_entry(
SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
)
platform.async_register_entity_service( # type: ignore
platform.async_register_entity_service(
SERVICE_SET_TIMER,
{
vol.Required(ATTR_SLEEP_TIME): vol.All(
@@ -163,9 +163,9 @@ async def async_setup_entry(
"set_sleep_timer",
)
platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, "clear_sleep_timer") # type: ignore
platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, "clear_sleep_timer")
platform.async_register_entity_service( # type: ignore
platform.async_register_entity_service(
SERVICE_UPDATE_ALARM,
{
vol.Required(ATTR_ALARM_ID): cv.positive_int,
@@ -177,13 +177,13 @@ async def async_setup_entry(
"set_alarm",
)
platform.async_register_entity_service( # type: ignore
platform.async_register_entity_service(
SERVICE_PLAY_QUEUE,
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
"play_queue",
)
platform.async_register_entity_service( # type: ignore
platform.async_register_entity_service(
SERVICE_REMOVE_FROM_QUEUE,
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
"remove_from_queue",
@@ -239,8 +239,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Return if the media_player is available."""
return (
self.speaker.available
and self.speaker.sonos_group_entities
and self.media.playback_status
and bool(self.speaker.sonos_group_entities)
and self.media.playback_status is not None
)
@property
@@ -257,7 +257,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Return a hash of self."""
return hash(self.unique_id)
@property # type: ignore[misc]
@property
def state(self) -> str:
"""Return the state of the entity."""
if self.media.playback_status in (
@@ -300,13 +300,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Return true if volume is muted."""
return self.speaker.muted
@property # type: ignore[misc]
def shuffle(self) -> str | None:
@property
def shuffle(self) -> bool | None:
"""Shuffling state."""
shuffle: str = PLAY_MODES[self.media.play_mode][0]
return shuffle
return PLAY_MODES[self.media.play_mode][0]
@property # type: ignore[misc]
@property
def repeat(self) -> str | None:
"""Return current repeat mode."""
sonos_repeat = PLAY_MODES[self.media.play_mode][1]
@@ -317,32 +316,32 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Return the SonosMedia object from the coordinator speaker."""
return self.coordinator.media
@property # type: ignore[misc]
@property
def media_content_id(self) -> str | None:
"""Content id of current playing media."""
return self.media.uri
@property # type: ignore[misc]
def media_duration(self) -> float | None:
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
return self.media.duration
return int(self.media.duration) if self.media.duration else None
@property # type: ignore[misc]
def media_position(self) -> float | None:
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
return self.media.position
return int(self.media.position) if self.media.position else None
@property # type: ignore[misc]
@property
def media_position_updated_at(self) -> datetime.datetime | None:
"""When was the position of the current playing media valid."""
return self.media.position_updated_at
@property # type: ignore[misc]
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
return self.media.image_url or None
@property # type: ignore[misc]
@property
def media_channel(self) -> str | None:
"""Channel currently playing."""
return self.media.channel or None
@@ -352,22 +351,22 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Title of playlist currently playing."""
return self.media.playlist_name
@property # type: ignore[misc]
@property
def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
return self.media.artist or None
@property # type: ignore[misc]
@property
def media_album_name(self) -> str | None:
"""Album name of current playing media, music track only."""
return self.media.album_name or None
@property # type: ignore[misc]
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
return self.media.title or None
@property # type: ignore[misc]
@property
def source(self) -> str | None:
"""Name of the current input source."""
return self.media.source_name or None
@@ -383,12 +382,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self.soco.volume -= VOLUME_INCREMENT
@soco_error()
def set_volume_level(self, volume: str) -> None:
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
self.soco.volume = str(int(volume * 100))
@soco_error(UPNP_ERRORS_TO_IGNORE)
def set_shuffle(self, shuffle: str) -> None:
def set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode."""
sonos_shuffle = shuffle
sonos_repeat = PLAY_MODES[self.media.play_mode][1]
@@ -486,7 +485,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self.coordinator.soco.previous()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_seek(self, position: str) -> None:
def media_seek(self, position: float) -> None:
"""Send seek command."""
self.coordinator.soco.seek(str(datetime.timedelta(seconds=int(position))))
@@ -606,7 +605,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
soco.play_uri(media_id, force_radio=is_radio)
elif media_type == MEDIA_TYPE_PLAYLIST:
if media_id.startswith("S:"):
item = media_browser.get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call]
item = media_browser.get_media(self.media.library, media_id, media_type)
soco.play_uri(item.get_uri())
return
try:
@@ -619,7 +618,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
soco.add_to_queue(playlist)
soco.play_from_queue(0)
elif media_type in PLAYABLE_MEDIA_TYPES:
item = media_browser.get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call]
item = media_browser.get_media(self.media.library, media_id, media_type)
if not item:
_LOGGER.error('Could not find "%s" in the library', media_id)
@@ -649,7 +648,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
include_linked_zones: bool | None = None,
) -> None:
"""Set the alarm clock on the player."""
alarm = None
alarm: alarms.Alarm | None = None
for one_alarm in alarms.get_alarms(self.coordinator.soco):
if one_alarm.alarm_id == str(alarm_id):
alarm = one_alarm
@@ -710,8 +709,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
MEDIA_TYPES_TO_SONOS[media_content_type],
)
if image_url := getattr(item, "album_art_uri", None):
result = await self._async_fetch_image(image_url) # type: ignore[no-untyped-call]
return result # type: ignore
return await self._async_fetch_image(image_url)
return (None, None)
@@ -728,7 +726,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
media_content_type,
)
async def async_join_players(self, group_members):
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
speakers = []
for entity_id in group_members:
@@ -739,7 +737,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
await SonosSpeaker.join_multi(self.hass, self.speaker, speakers)
async def async_unjoin_player(self):
async def async_unjoin_player(self) -> None:
"""Remove this player from any group.
Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to allow use of SonosSpeaker.unjoin_multi()
+7 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import cast
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
@@ -24,6 +25,8 @@ LEVEL_TYPES = {
"music_surround_level": (-15, 15),
}
SocoFeatures = list[tuple[str, tuple[int, int]]]
_LOGGER = logging.getLogger(__name__)
@@ -34,8 +37,8 @@ async def async_setup_entry(
) -> None:
"""Set up the Sonos number platform from a config entry."""
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
features = []
def available_soco_attributes(speaker: SonosSpeaker) -> SocoFeatures:
features: SocoFeatures = []
for level_type, valid_range in LEVEL_TYPES.items():
if (state := getattr(speaker.soco, level_type, None)) is not None:
setattr(speaker, level_type, state)
@@ -67,7 +70,7 @@ class SonosLevelEntity(SonosEntity, NumberEntity):
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int]
self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int, int]
) -> None:
"""Initialize the level entity."""
super().__init__(speaker)
@@ -94,4 +97,4 @@ class SonosLevelEntity(SonosEntity, NumberEntity):
@property
def native_value(self) -> float:
"""Return the current value."""
return getattr(self.speaker, self.level_type)
return cast(float, getattr(self.speaker, self.level_type))
+1 -1
View File
@@ -100,7 +100,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
@property
def available(self) -> bool:
"""Return whether this device is available."""
return self.speaker.available and self.speaker.power_source
return self.speaker.available and self.speaker.power_source is not None
class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
+31 -29
View File
@@ -8,7 +8,7 @@ import datetime
from functools import partial
import logging
import time
from typing import Any
from typing import Any, cast
import async_timeout
import defusedxml.ElementTree as ET
@@ -97,17 +97,17 @@ class SonosSpeaker:
self.media = SonosMedia(hass, soco)
self._plex_plugin: PlexPlugin | None = None
self._share_link_plugin: ShareLinkPlugin | None = None
self.available = True
self.available: bool = True
# Device information
self.hardware_version = speaker_info["hardware_version"]
self.software_version = speaker_info["software_version"]
self.mac_address = speaker_info["mac_address"]
self.model_name = speaker_info["model_name"]
self.model_number = speaker_info["model_number"]
self.uid = speaker_info["uid"]
self.version = speaker_info["display_version"]
self.zone_name = speaker_info["zone_name"]
self.hardware_version: str = speaker_info["hardware_version"]
self.software_version: str = speaker_info["software_version"]
self.mac_address: str = speaker_info["mac_address"]
self.model_name: str = speaker_info["model_name"]
self.model_number: str = speaker_info["model_number"]
self.uid: str = speaker_info["uid"]
self.version: str = speaker_info["display_version"]
self.zone_name: str = speaker_info["zone_name"]
# Subscriptions and events
self.subscriptions_failed: bool = False
@@ -160,12 +160,12 @@ class SonosSpeaker:
self.sonos_group: list[SonosSpeaker] = [self]
self.sonos_group_entities: list[str] = []
self.soco_snapshot: Snapshot | None = None
self.snapshot_group: list[SonosSpeaker] | None = None
self.snapshot_group: list[SonosSpeaker] = []
self._group_members_missing: set[str] = set()
async def async_setup_dispatchers(self, entry: ConfigEntry) -> None:
"""Connect dispatchers in async context during setup."""
dispatch_pairs = (
dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = (
(SONOS_CHECK_ACTIVITY, self.async_check_activity),
(SONOS_SPEAKER_ADDED, self.update_group_for_uid),
(f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted),
@@ -283,18 +283,17 @@ class SonosSpeaker:
return self._share_link_plugin
@property
def subscription_address(self) -> str | None:
"""Return the current subscription callback address if any."""
if self._subscriptions:
addr, port = self._subscriptions[0].event_listener.address
return ":".join([addr, str(port)])
return None
def subscription_address(self) -> str:
"""Return the current subscription callback address."""
assert len(self._subscriptions) > 0
addr, port = self._subscriptions[0].event_listener.address
return ":".join([addr, str(port)])
#
# Subscription handling and event dispatchers
#
def log_subscription_result(
self, result: Any, event: str, level: str = logging.DEBUG
self, result: Any, event: str, level: int = logging.DEBUG
) -> None:
"""Log a message if a subscription action (create/renew/stop) results in an exception."""
if not isinstance(result, Exception):
@@ -304,7 +303,7 @@ class SonosSpeaker:
message = "Request timed out"
exc_info = None
else:
message = result
message = str(result)
exc_info = result if not str(result) else None
_LOGGER.log(
@@ -554,7 +553,7 @@ class SonosSpeaker:
)
@callback
def speaker_activity(self, source):
def speaker_activity(self, source: str) -> None:
"""Track the last activity on this speaker, set availability and resubscribe."""
if self._resub_cooldown_expires_at:
if time.monotonic() < self._resub_cooldown_expires_at:
@@ -593,6 +592,7 @@ class SonosSpeaker:
async def async_offline(self) -> None:
"""Handle removal of speaker when unavailable."""
assert self._subscription_lock is not None
async with self._subscription_lock:
await self._async_offline()
@@ -826,8 +826,8 @@ class SonosSpeaker:
if speaker:
self._group_members_missing.discard(uid)
sonos_group.append(speaker)
entity_id = entity_registry.async_get_entity_id(
MP_DOMAIN, DOMAIN, uid
entity_id = cast(
str, entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid)
)
sonos_group_entities.append(entity_id)
else:
@@ -850,7 +850,9 @@ class SonosSpeaker:
self.async_write_entity_states()
for joined_uid in group[1:]:
joined_speaker = self.hass.data[DATA_SONOS].discovered.get(joined_uid)
joined_speaker: SonosSpeaker = self.hass.data[
DATA_SONOS
].discovered.get(joined_uid)
if joined_speaker:
joined_speaker.coordinator = self
joined_speaker.sonos_group = sonos_group
@@ -936,7 +938,7 @@ class SonosSpeaker:
if with_group:
self.snapshot_group = self.sonos_group.copy()
else:
self.snapshot_group = None
self.snapshot_group = []
@staticmethod
async def snapshot_multi(
@@ -969,7 +971,7 @@ class SonosSpeaker:
_LOGGER.warning("Error on restore %s: %s", self.zone_name, ex)
self.soco_snapshot = None
self.snapshot_group = None
self.snapshot_group = []
@staticmethod
async def restore_multi(
@@ -996,7 +998,7 @@ class SonosSpeaker:
exc_info=exc,
)
groups = []
groups: list[list[SonosSpeaker]] = []
if not with_group:
return groups
@@ -1022,7 +1024,7 @@ class SonosSpeaker:
# Bring back the original group topology
for speaker in (s for s in speakers if s.snapshot_group):
assert speaker.snapshot_group is not None
assert len(speaker.snapshot_group)
if speaker.snapshot_group[0] == speaker:
if speaker.snapshot_group not in (speaker.sonos_group, [speaker]):
speaker.join(speaker.snapshot_group)
@@ -1047,7 +1049,7 @@ class SonosSpeaker:
if with_group:
for speaker in [s for s in speakers_set if s.snapshot_group]:
assert speaker.snapshot_group is not None
assert len(speaker.snapshot_group)
speakers_set.update(speaker.snapshot_group)
async with hass.data[DATA_SONOS].topology_condition:
+1 -1
View File
@@ -14,7 +14,7 @@ class SonosStatistics:
def __init__(self, zone_name: str, kind: str) -> None:
"""Initialize SonosStatistics."""
self._stats = {}
self._stats: dict[str, dict[str, int | float]] = {}
self._stat_type = kind
self.zone_name = zone_name
+18 -19
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
import datetime
import logging
from typing import Any
from typing import Any, cast
from soco.alarms import Alarm
from soco.exceptions import SoCoSlaveException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
@@ -183,14 +184,14 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.needs_coordinator and not self.speaker.is_coordinator:
return getattr(self.speaker.coordinator, self.feature_type)
return getattr(self.speaker, self.feature_type)
return cast(bool, getattr(self.speaker.coordinator, self.feature_type))
return cast(bool, getattr(self.speaker, self.feature_type))
def turn_on(self, **kwargs) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
self.send_command(True)
def turn_off(self, **kwargs) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
self.send_command(False)
@@ -233,7 +234,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
)
@property
def alarm(self):
def alarm(self) -> Alarm:
"""Return the alarm instance."""
return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id)
@@ -247,7 +248,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll()
@callback
def async_check_if_available(self):
def async_check_if_available(self) -> bool:
"""Check if alarm exists and remove alarm entity if not available."""
if self.alarm:
return True
@@ -279,7 +280,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
self.async_write_ha_state()
@callback
def _async_update_device(self):
def _async_update_device(self) -> None:
"""Update the device, since this alarm moved to a different player."""
device_registry = dr.async_get(self.hass)
entity_registry = er.async_get(self.hass)
@@ -288,22 +289,20 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
if entity is None:
raise RuntimeError("Alarm has been deleted by accident.")
entry_id = entity.config_entry_id
new_device = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_entry_id=cast(str, entity.config_entry_id),
identifiers={(SONOS_DOMAIN, self.soco.uid)},
connections={(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)},
)
if not entity_registry.async_get(self.entity_id).device_id == new_device.id:
if (
device := entity_registry.async_get(self.entity_id)
) and device.device_id != new_device.id:
_LOGGER.debug("%s is moving to %s", self.entity_id, new_device.name)
# pylint: disable=protected-access
entity_registry._async_update_entity(
self.entity_id, device_id=new_device.id
)
entity_registry.async_update_entity(self.entity_id, device_id=new_device.id)
@property
def _is_today(self):
def _is_today(self) -> bool:
"""Return whether this alarm is scheduled for today."""
recurrence = self.alarm.recurrence
timestr = int(datetime.datetime.today().strftime("%w"))
return (
@@ -321,12 +320,12 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
return (self.alarm is not None) and self.speaker.available
@property
def is_on(self):
def is_on(self) -> bool:
"""Return state of Sonos alarm switch."""
return self.alarm.enabled
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return attributes of Sonos alarm switch."""
return {
ATTR_ID: str(self.alarm_id),
@@ -102,6 +102,7 @@ class SynoApi:
self.dsm.upgrade.update()
except SynologyDSMAPIErrorException as ex:
self._with_upgrade = False
self.dsm.reset(SynoCoreUpgrade.API_KEY)
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
self._fetch_device_configuration()
@@ -101,12 +101,12 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
@callback
def _get_start_end(hass: HomeAssistant, start: datetime) -> tuple[datetime, datetime]:
def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
start = dt_util.as_local(start)
end = dt_util.now()
start = start.replace(day=1, hour=1, minute=0, second=0, microsecond=0)
end = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start = start.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
end = end.replace(day=1, hour=0, minute=0, second=2, microsecond=0)
return start, end
@@ -571,9 +571,16 @@ class ProtectMediaSource(MediaSource):
if not build_children:
return source
month = start.month
if data.api.bootstrap.recording_start is not None:
recording_start = data.api.bootstrap.recording_start.date()
start = max(recording_start, start)
recording_end = dt_util.now().date()
end = start.replace(month=start.month + 1) - timedelta(days=1)
end = min(recording_end, end)
children = [self._build_days(data, camera_id, event_type, start, is_all=True)]
while start.month == month:
while start <= end:
children.append(
self._build_days(data, camera_id, event_type, start, is_all=False)
)
@@ -702,7 +709,7 @@ class ProtectMediaSource(MediaSource):
self._build_recent(data, camera_id, event_type, 30),
]
start, end = _get_start_end(self.hass, data.api.bootstrap.recording_start)
start, end = _get_month_start_end(data.api.bootstrap.recording_start)
while end > start:
children.append(self._build_month(data, camera_id, event_type, end.date()))
end = (end - timedelta(days=1)).replace(day=1)
@@ -387,8 +387,6 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) ->
if gateway_id.endswith("-gateway"):
hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"])
entry.async_on_unload(entry.add_update_listener(update_listener))
# Connect to gateway
gateway = ConnectXiaomiGateway(hass, entry)
try:
@@ -444,6 +442,8 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Xiaomi Miio device component from a config entry."""
@@ -453,10 +453,10 @@ async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
if not platforms:
return False
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, platforms)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -112,6 +112,15 @@ MODELS_FAN_MIOT = [
MODEL_FAN_ZA5,
]
# number of speed levels each fan has
SPEEDS_FAN_MIOT = {
MODEL_FAN_1C: 3,
MODEL_FAN_P10: 4,
MODEL_FAN_P11: 4,
MODEL_FAN_P9: 4,
MODEL_FAN_ZA5: 4,
}
MODELS_PURIFIER_MIOT = [
MODEL_AIRPURIFIER_3,
MODEL_AIRPURIFIER_3C,
+27 -9
View File
@@ -85,6 +85,7 @@ from .const import (
MODELS_PURIFIER_MIOT,
SERVICE_RESET_FILTER,
SERVICE_SET_EXTRA_FEATURES,
SPEEDS_FAN_MIOT,
)
from .device import XiaomiCoordinatedMiioEntity
@@ -234,9 +235,13 @@ async def async_setup_entry(
elif model in MODELS_FAN_MIIO:
entity = XiaomiFan(device, config_entry, unique_id, coordinator)
elif model == MODEL_FAN_ZA5:
entity = XiaomiFanZA5(device, config_entry, unique_id, coordinator)
speed_count = SPEEDS_FAN_MIOT[model]
entity = XiaomiFanZA5(device, config_entry, unique_id, coordinator, speed_count)
elif model in MODELS_FAN_MIOT:
entity = XiaomiFanMiot(device, config_entry, unique_id, coordinator)
speed_count = SPEEDS_FAN_MIOT[model]
entity = XiaomiFanMiot(
device, config_entry, unique_id, coordinator, speed_count
)
else:
return
@@ -1044,6 +1049,11 @@ class XiaomiFanP5(XiaomiGenericFan):
class XiaomiFanMiot(XiaomiGenericFan):
"""Representation of a Xiaomi Fan Miot."""
def __init__(self, device, entry, unique_id, coordinator, speed_count):
"""Initialize MIOT fan with speed count."""
super().__init__(device, entry, unique_id, coordinator)
self._speed_count = speed_count
@property
def operation_mode_class(self):
"""Hold operation mode class."""
@@ -1061,7 +1071,9 @@ class XiaomiFanMiot(XiaomiGenericFan):
self._preset_mode = self.coordinator.data.mode.name
self._oscillating = self.coordinator.data.oscillate
if self.coordinator.data.is_on:
self._percentage = self.coordinator.data.speed
self._percentage = ranged_value_to_percentage(
(1, self._speed_count), self.coordinator.data.speed
)
else:
self._percentage = 0
@@ -1087,16 +1099,22 @@ class XiaomiFanMiot(XiaomiGenericFan):
await self.async_turn_off()
return
await self._try_command(
"Setting fan speed percentage of the miio device failed.",
self._device.set_speed,
percentage,
speed = math.ceil(
percentage_to_ranged_value((1, self._speed_count), percentage)
)
self._percentage = percentage
# if the fan is not on, we have to turn it on first
if not self.is_on:
await self.async_turn_on()
else:
result = await self._try_command(
"Setting fan speed percentage of the miio device failed.",
self._device.set_speed,
speed,
)
if result:
self._percentage = ranged_value_to_percentage((1, self._speed_count), speed)
self.async_write_ha_state()
@@ -2,7 +2,7 @@
"domain": "yale_smart_alarm",
"name": "Yale Smart Living",
"documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm",
"requirements": ["yalesmartalarmclient==0.3.8"],
"requirements": ["yalesmartalarmclient==0.3.9"],
"codeowners": ["@gjohansson-ST"],
"config_flow": true,
"iot_class": "cloud_polling",
+4 -6
View File
@@ -9,11 +9,11 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DATA_CLIENT, DOMAIN, LOGGER
from .helpers import get_device_id, get_valueless_base_unique_id
from .helpers import get_device_info, get_valueless_base_unique_id
PARALLEL_UPDATES = 0
@@ -58,10 +58,8 @@ class ZWaveNodePingButton(ButtonEntity):
self._attr_name = f"{name}: Ping"
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.ping"
# device is precreated in main handler
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, node)},
)
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
+3 -1
View File
@@ -123,4 +123,6 @@ ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing"
# This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for
# your own (https://github.com/zwave-js/firmware-updates/).
API_KEY_FIRMWARE_UPDATE_SERVICE = "b48e74337db217f44e1e003abb1e9144007d260a17e2b2422e0a45d0eaf6f4ad86f2a9943f17fee6dde343941f238a64"
API_KEY_FIRMWARE_UPDATE_SERVICE = (
"55eea74f055bef2ad893348112df6a38980600aaf82d2b02011297fc7ba495f830ca2b70"
)
@@ -30,6 +30,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -413,3 +414,15 @@ def get_value_state_schema(
vol.Coerce(int),
vol.Range(min=value.metadata.min, max=value.metadata.max),
)
def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo:
"""Get DeviceInfo for node."""
return DeviceInfo(
identifiers={get_device_id(driver, node)},
sw_version=node.firmware_version,
name=node.name or node.device_config.description or f"Node {node.node_id}",
model=node.device_config.label,
manufacturer=node.device_config.manufacturer,
suggested_area=node.location if node.location else None,
)
+4 -6
View File
@@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@@ -63,7 +63,7 @@ from .discovery_data_template import (
NumericSensorDataTemplateData,
)
from .entity import ZWaveBaseEntity
from .helpers import get_device_id, get_valueless_base_unique_id
from .helpers import get_device_info, get_valueless_base_unique_id
PARALLEL_UPDATES = 0
@@ -493,10 +493,8 @@ class ZWaveNodeStatusSensor(SensorEntity):
self._attr_name = f"{name}: Node Status"
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.node_status"
# device is precreated in main handler
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, self.node)},
)
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
self._attr_native_value: str = node.status.name.lower()
async def async_poll_value(self, _: bool) -> None:
+15 -15
View File
@@ -19,11 +19,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER
from .helpers import get_device_id, get_valueless_base_unique_id
from .helpers import get_device_info, get_valueless_base_unique_id
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(days=1)
@@ -75,28 +75,28 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.firmware_update"
# device may not be precreated in main handler yet
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, node)},
sw_version=node.firmware_version,
name=node.name or node.device_config.description or f"Node {node.node_id}",
model=node.device_config.label,
manufacturer=node.device_config.manufacturer,
suggested_area=node.location if node.location else None,
)
self._attr_device_info = get_device_info(driver, node)
self._attr_installed_version = self._attr_latest_version = node.firmware_version
def _update_on_wake_up(self, _: dict[str, Any]) -> None:
def _update_on_status_change(self, _: dict[str, Any]) -> None:
"""Update the entity when node is awake."""
self._status_unsub = None
self.hass.async_create_task(self.async_update(True))
async def async_update(self, write_state: bool = False) -> None:
"""Update the entity."""
if self.node.status == NodeStatus.ASLEEP:
if not self._status_unsub:
self._status_unsub = self.node.once("wake up", self._update_on_wake_up)
return
for status, event_name in (
(NodeStatus.ASLEEP, "wake up"),
(NodeStatus.DEAD, "alive"),
):
if self.node.status == status:
if not self._status_unsub:
self._status_unsub = self.node.once(
event_name, self._update_on_status_change
)
return
if available_firmware_updates := (
await self.driver.controller.async_get_available_firmware_updates(
self.node, API_KEY_FIRMWARE_UPDATE_SERVICE
+1 -1
View File
@@ -7,7 +7,7 @@ from .backports.enum import StrEnum
MAJOR_VERSION: Final = 2022
MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "0b2"
PATCH_VERSION: Final = "0b5"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
+17
View File
@@ -7,6 +7,11 @@ from __future__ import annotations
# fmt: off
BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
{
"domain": "bluemaestro",
"manufacturer_id": 307,
"connectable": False
},
{
"domain": "bthome",
"connectable": False,
@@ -151,6 +156,18 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
"domain": "led_ble",
"local_name": "LEDBlue*"
},
{
"domain": "led_ble",
"local_name": "Dream~*"
},
{
"domain": "led_ble",
"local_name": "QHM-*"
},
{
"domain": "led_ble",
"local_name": "AP-*"
},
{
"domain": "melnor",
"manufacturer_data_start": [
+1
View File
@@ -48,6 +48,7 @@ FLOWS = {
"balboa",
"blebox",
"blink",
"bluemaestro",
"bluetooth",
"bmw_connected_drive",
"bond",
+3 -2
View File
@@ -11,7 +11,7 @@ attrs==21.2.0
awesomeversion==22.8.0
bcrypt==3.1.7
bleak==0.16.0
bluetooth-adapters==0.3.3
bluetooth-adapters==0.3.4
bluetooth-auto-recovery==0.3.0
certifi>=2021.5.30
ciso8601==2.2.0
@@ -19,7 +19,7 @@ cryptography==37.0.4
fnvhash==0.1.0
hass-nabucasa==0.55.0
home-assistant-bluetooth==1.3.0
home-assistant-frontend==20220901.0
home-assistant-frontend==20220905.0
httpx==0.23.0
ifaddr==0.1.7
jinja2==3.1.2
@@ -28,6 +28,7 @@ orjson==3.7.11
paho-mqtt==1.6.1
pillow==9.2.0
pip>=21.0,<22.3
psutil-home-assistant==0.0.1
pyserial==3.5
python-slugify==4.0.1
pyudev==0.23.2
-36
View File
@@ -2589,39 +2589,3 @@ disallow_untyped_decorators = false
disallow_untyped_defs = false
warn_return_any = false
warn_unreachable = false
[mypy-homeassistant.components.sonos]
ignore_errors = true
[mypy-homeassistant.components.sonos.alarms]
ignore_errors = true
[mypy-homeassistant.components.sonos.binary_sensor]
ignore_errors = true
[mypy-homeassistant.components.sonos.diagnostics]
ignore_errors = true
[mypy-homeassistant.components.sonos.entity]
ignore_errors = true
[mypy-homeassistant.components.sonos.favorites]
ignore_errors = true
[mypy-homeassistant.components.sonos.media_browser]
ignore_errors = true
[mypy-homeassistant.components.sonos.media_player]
ignore_errors = true
[mypy-homeassistant.components.sonos.number]
ignore_errors = true
[mypy-homeassistant.components.sonos.sensor]
ignore_errors = true
[mypy-homeassistant.components.sonos.speaker]
ignore_errors = true
[mypy-homeassistant.components.sonos.statistics]
ignore_errors = true
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2022.9.0b2"
version = "2022.9.0b5"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
+16 -13
View File
@@ -422,12 +422,15 @@ blinkstick==1.2.0
# homeassistant.components.bitcoin
blockchain==1.4.4
# homeassistant.components.bluemaestro
bluemaestro-ble==0.2.0
# homeassistant.components.decora
# homeassistant.components.zengge
# bluepy==1.3.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.3.3
bluetooth-adapters==0.3.4
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.0
@@ -461,7 +464,7 @@ bsblan==0.5.0
bt_proximity==0.2.1
# homeassistant.components.bthome
bthome-ble==0.5.2
bthome-ble==1.0.0
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -679,7 +682,7 @@ fjaraskupan==2.0.0
flipr-api==1.4.2
# homeassistant.components.flux_led
flux_led==0.28.31
flux_led==0.28.32
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -769,7 +772,7 @@ googlemaps==2.5.1
goslide-api==0.5.1
# homeassistant.components.govee_ble
govee-ble==0.17.1
govee-ble==0.17.2
# homeassistant.components.remote_rpi_gpio
gpiozero==1.6.2
@@ -848,7 +851,7 @@ hole==0.7.0
holidays==0.14.2
# homeassistant.components.frontend
home-assistant-frontend==20220901.0
home-assistant-frontend==20220905.0
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -965,7 +968,7 @@ lakeside==0.12
laundrify_aio==1.1.2
# homeassistant.components.led_ble
led-ble==0.5.4
led-ble==0.7.0
# homeassistant.components.foscam
libpyfoscam==1.0
@@ -1348,6 +1351,9 @@ py-nightscout==1.2.2
# homeassistant.components.schluter
py-schluter==0.1.7
# homeassistant.components.ecovacs
py-sucks==0.9.8
# homeassistant.components.synology_dsm
py-synologydsm-api==1.0.8
@@ -1437,7 +1443,7 @@ pyblackbird==0.5
pybotvac==0.0.23
# homeassistant.components.braviatv
pybravia==0.2.0
pybravia==0.2.1
# homeassistant.components.nissan_leaf
pycarwings2==2.13
@@ -1832,7 +1838,7 @@ pysaj==0.0.16
pysdcp==1
# homeassistant.components.sensibo
pysensibo==1.0.18
pysensibo==1.0.19
# homeassistant.components.serial
# homeassistant.components.zha
@@ -1990,7 +1996,7 @@ python-qbittorrent==0.4.2
python-ripple-api==0.0.3
# homeassistant.components.smarttub
python-smarttub==0.0.32
python-smarttub==0.0.33
# homeassistant.components.songpal
python-songpal==0.15
@@ -2305,9 +2311,6 @@ stringcase==1.2.0
# homeassistant.components.subaru
subarulink==0.5.0
# homeassistant.components.ecovacs
sucks==0.9.4
# homeassistant.components.solarlog
sunwatcher==0.2.1
@@ -2536,7 +2539,7 @@ xmltodict==0.13.0
xs1-api-client==3.0.0
# homeassistant.components.yale_smart_alarm
yalesmartalarmclient==0.3.8
yalesmartalarmclient==0.3.9
# homeassistant.components.yalexs_ble
yalexs-ble==1.6.4
+13 -10
View File
@@ -337,8 +337,11 @@ blebox_uniapi==2.0.2
# homeassistant.components.blink
blinkpy==0.19.0
# homeassistant.components.bluemaestro
bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.3.3
bluetooth-adapters==0.3.4
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.0
@@ -362,7 +365,7 @@ brunt==1.2.0
bsblan==0.5.0
# homeassistant.components.bthome
bthome-ble==0.5.2
bthome-ble==1.0.0
# homeassistant.components.buienradar
buienradar==1.0.5
@@ -498,7 +501,7 @@ fjaraskupan==2.0.0
flipr-api==1.4.2
# homeassistant.components.flux_led
flux_led==0.28.31
flux_led==0.28.32
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -570,7 +573,7 @@ google-nest-sdm==2.0.0
googlemaps==2.5.1
# homeassistant.components.govee_ble
govee-ble==0.17.1
govee-ble==0.17.2
# homeassistant.components.gree
greeclimate==1.3.0
@@ -625,7 +628,7 @@ hole==0.7.0
holidays==0.14.2
# homeassistant.components.frontend
home-assistant-frontend==20220901.0
home-assistant-frontend==20220905.0
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -703,7 +706,7 @@ lacrosse-view==0.0.9
laundrify_aio==1.1.2
# homeassistant.components.led_ble
led-ble==0.5.4
led-ble==0.7.0
# homeassistant.components.foscam
libpyfoscam==1.0
@@ -1016,7 +1019,7 @@ pyblackbird==0.5
pybotvac==0.0.23
# homeassistant.components.braviatv
pybravia==0.2.0
pybravia==0.2.1
# homeassistant.components.cloudflare
pycfdns==1.2.2
@@ -1282,7 +1285,7 @@ pyruckus==0.16
pysabnzbd==1.1.1
# homeassistant.components.sensibo
pysensibo==1.0.18
pysensibo==1.0.19
# homeassistant.components.serial
# homeassistant.components.zha
@@ -1365,7 +1368,7 @@ python-nest==4.2.0
python-picnic-api==1.1.0
# homeassistant.components.smarttub
python-smarttub==0.0.32
python-smarttub==0.0.33
# homeassistant.components.songpal
python-songpal==0.15
@@ -1740,7 +1743,7 @@ xknx==1.0.2
xmltodict==0.13.0
# homeassistant.components.yale_smart_alarm
yalesmartalarmclient==0.3.8
yalesmartalarmclient==0.3.9
# homeassistant.components.yalexs_ble
yalexs-ble==1.6.4
+1 -14
View File
@@ -15,20 +15,7 @@ from .model import Config, Integration
# If you are an author of component listed here, please fix these errors and
# remove your component from this list to enable type checks.
# Do your best to not add anything new here.
IGNORED_MODULES: Final[list[str]] = [
"homeassistant.components.sonos",
"homeassistant.components.sonos.alarms",
"homeassistant.components.sonos.binary_sensor",
"homeassistant.components.sonos.diagnostics",
"homeassistant.components.sonos.entity",
"homeassistant.components.sonos.favorites",
"homeassistant.components.sonos.media_browser",
"homeassistant.components.sonos.media_player",
"homeassistant.components.sonos.number",
"homeassistant.components.sonos.sensor",
"homeassistant.components.sonos.speaker",
"homeassistant.components.sonos.statistics",
]
IGNORED_MODULES: Final[list[str]] = []
# Component modules which should set no_implicit_reexport = true.
NO_IMPLICIT_REEXPORT_MODULES: set[str] = {
+26
View File
@@ -0,0 +1,26 @@
"""Tests for the BlueMaestro integration."""
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
NOT_BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo(
name="Not it",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
manufacturer_data={3234: b"\x00\x01"},
service_data={},
service_uuids=[],
source="local",
)
BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo(
name="FA17B62C",
manufacturer_data={
307: b"\x17d\x0e\x10\x00\x02\x00\xf2\x01\xf2\x00\x83\x01\x00\x01\r\x02\xab\x00\xf2\x01\xf2\x01\r\x02\xab\x00\xf2\x01\xf2\x00\xff\x02N\x00\x00\x00\x00\x00"
},
address="aa:bb:cc:dd:ee:ff",
rssi=-60,
service_data={},
service_uuids=[],
source="local",
)
+8
View File
@@ -0,0 +1,8 @@
"""BlueMaestro session fixtures."""
import pytest
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
@@ -0,0 +1,200 @@
"""Test the BlueMaestro config flow."""
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.bluemaestro.const import DOMAIN
from homeassistant.data_entry_flow import FlowResultType
from . import BLUEMAESTRO_SERVICE_INFO, NOT_BLUEMAESTRO_SERVICE_INFO
from tests.common import MockConfigEntry
async def test_async_step_bluetooth_valid_device(hass):
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
with patch(
"homeassistant.components.bluemaestro.async_setup_entry", return_value=True
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Tempo Disc THD EEFF"
assert result2["data"] == {}
assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff"
async def test_async_step_bluetooth_not_bluemaestro(hass):
"""Test discovery via bluetooth not bluemaestro."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=NOT_BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_supported"
async def test_async_step_user_no_devices_found(hass):
"""Test setup from service info cache with no devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_async_step_user_with_found_devices(hass):
"""Test setup from service info cache with devices found."""
with patch(
"homeassistant.components.bluemaestro.config_flow.async_discovered_service_info",
return_value=[BLUEMAESTRO_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.bluemaestro.async_setup_entry", return_value=True
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"address": "aa:bb:cc:dd:ee:ff"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Tempo Disc THD EEFF"
assert result2["data"] == {}
assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff"
async def test_async_step_user_device_added_between_steps(hass):
"""Test the device gets added via another flow between steps."""
with patch(
"homeassistant.components.bluemaestro.config_flow.async_discovered_service_info",
return_value=[BLUEMAESTRO_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.bluemaestro.async_setup_entry", return_value=True
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"address": "aa:bb:cc:dd:ee:ff"},
)
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "already_configured"
async def test_async_step_user_with_found_devices_already_setup(hass):
"""Test setup from service info cache with devices found."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.bluemaestro.config_flow.async_discovered_service_info",
return_value=[BLUEMAESTRO_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_async_step_bluetooth_devices_already_setup(hass):
"""Test we can't start a flow if there is already a config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_bluetooth_already_in_progress(hass):
"""Test we can't start a flow for the same device twice."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_in_progress"
async def test_async_step_user_takes_precedence_over_discovery(hass):
"""Test manual setup takes precedence over discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BLUEMAESTRO_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
with patch(
"homeassistant.components.bluemaestro.config_flow.async_discovered_service_info",
return_value=[BLUEMAESTRO_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
with patch(
"homeassistant.components.bluemaestro.async_setup_entry", return_value=True
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"address": "aa:bb:cc:dd:ee:ff"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Tempo Disc THD EEFF"
assert result2["data"] == {}
assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff"
# Verify the original one was aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
@@ -0,0 +1,50 @@
"""Test the BlueMaestro sensors."""
from unittest.mock import patch
from homeassistant.components.bluemaestro.const import DOMAIN
from homeassistant.components.bluetooth import BluetoothChange
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from . import BLUEMAESTRO_SERVICE_INFO
from tests.common import MockConfigEntry
async def test_sensors(hass):
"""Test setting up creates the sensors."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff",
)
entry.add_to_hass(hass)
saved_callback = None
def _async_register_callback(_hass, _callback, _matcher, _mode):
nonlocal saved_callback
saved_callback = _callback
return lambda: None
with patch(
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
_async_register_callback,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 0
saved_callback(BLUEMAESTRO_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 4
humid_sensor = hass.states.get("sensor.tempo_disc_thd_eeff_temperature")
humid_sensor_attrs = humid_sensor.attributes
assert humid_sensor.state == "24.2"
assert humid_sensor_attrs[ATTR_FRIENDLY_NAME] == "Tempo Disc THD EEFF Temperature"
assert humid_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C"
assert humid_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@@ -5,6 +5,8 @@ import asyncio
import logging
from unittest.mock import MagicMock, call, patch
from bleak import BleakError
from homeassistant.components.bluetooth import (
DOMAIN,
BluetoothChange,
@@ -162,6 +164,80 @@ async def test_poll_can_be_skipped(hass: HomeAssistant, mock_bleak_scanner_start
cancel()
async def test_bleak_error_and_recover(
hass: HomeAssistant, mock_bleak_scanner_start, caplog
):
"""Test bleak error handling and recovery."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
flag = True
def _update_method(service_info: BluetoothServiceInfoBleak):
return {"testdata": None}
def _poll_needed(*args, **kwargs):
return True
async def _poll(*args, **kwargs):
nonlocal flag
if flag:
raise BleakError("Connection was aborted")
return {"testdata": flag}
coordinator = ActiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address="aa:bb:cc:dd:ee:ff",
mode=BluetoothScanningMode.ACTIVE,
update_method=_update_method,
needs_poll_method=_poll_needed,
poll_method=_poll,
poll_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=0,
immediate=True,
),
)
assert coordinator.available is False # no data yet
saved_callback = None
processor = MagicMock()
coordinator.async_register_processor(processor)
async_handle_update = processor.async_handle_update
def _async_register_callback(_hass, _callback, _matcher, _mode):
nonlocal saved_callback
saved_callback = _callback
return lambda: None
with patch(
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
_async_register_callback,
):
cancel = coordinator.async_start()
assert saved_callback is not None
# First poll fails
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done()
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
assert (
"aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted"
in caplog.text
)
# Second poll works
flag = False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done()
assert async_handle_update.mock_calls[-1] == call({"testdata": False})
cancel()
async def test_poll_failure_and_recover(hass: HomeAssistant, mock_bleak_scanner_start):
"""Test error handling and recovery."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+89 -39
View File
@@ -1291,16 +1291,16 @@ async def test_register_callback_by_manufacturer_id(
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{MANUFACTURER_ID: 76},
{MANUFACTURER_ID: 21},
BluetoothScanningMode.ACTIVE,
)
assert len(mock_bleak_scanner_start.mock_calls) == 1
apple_device = BLEDevice("44:44:33:11:23:45", "apple")
apple_device = BLEDevice("44:44:33:11:23:45", "rtx")
apple_adv = AdvertisementData(
local_name="apple",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
local_name="rtx",
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device, apple_adv)
@@ -1316,9 +1316,59 @@ async def test_register_callback_by_manufacturer_id(
assert len(callbacks) == 1
service_info: BluetoothServiceInfo = callbacks[0][0]
assert service_info.name == "apple"
assert service_info.manufacturer == "Apple, Inc."
assert service_info.manufacturer_id == 76
assert service_info.name == "rtx"
assert service_info.manufacturer == "RTX Telecom A/S"
assert service_info.manufacturer_id == 21
async def test_filtering_noisy_apple_devices(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test filtering noisy apple devices."""
mock_bt = []
callbacks = []
def _fake_subscriber(
service_info: BluetoothServiceInfo, change: BluetoothChange
) -> None:
"""Fake subscriber for the BleakScanner."""
callbacks.append((service_info, change))
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{MANUFACTURER_ID: 21},
BluetoothScanningMode.ACTIVE,
)
assert len(mock_bleak_scanner_start.mock_calls) == 1
apple_device = BLEDevice("44:44:33:11:23:45", "rtx")
apple_adv = AdvertisementData(
local_name="noisy",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device, apple_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty")
inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done()
cancel()
assert len(callbacks) == 0
async def test_register_callback_by_address_connectable_manufacturer_id(
@@ -1346,21 +1396,21 @@ async def test_register_callback_by_address_connectable_manufacturer_id(
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{MANUFACTURER_ID: 76, CONNECTABLE: False, ADDRESS: "44:44:33:11:23:45"},
{MANUFACTURER_ID: 21, CONNECTABLE: False, ADDRESS: "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
)
assert len(mock_bleak_scanner_start.mock_calls) == 1
apple_device = BLEDevice("44:44:33:11:23:45", "apple")
apple_device = BLEDevice("44:44:33:11:23:45", "rtx")
apple_adv = AdvertisementData(
local_name="apple",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
local_name="rtx",
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device, apple_adv)
apple_device_wrong_address = BLEDevice("44:44:33:11:23:46", "apple")
apple_device_wrong_address = BLEDevice("44:44:33:11:23:46", "rtx")
inject_advertisement(hass, apple_device_wrong_address, apple_adv)
await hass.async_block_till_done()
@@ -1370,9 +1420,9 @@ async def test_register_callback_by_address_connectable_manufacturer_id(
assert len(callbacks) == 1
service_info: BluetoothServiceInfo = callbacks[0][0]
assert service_info.name == "apple"
assert service_info.manufacturer == "Apple, Inc."
assert service_info.manufacturer_id == 76
assert service_info.name == "rtx"
assert service_info.manufacturer == "RTX Telecom A/S"
assert service_info.manufacturer_id == 21
async def test_register_callback_by_manufacturer_id_and_address(
@@ -1400,19 +1450,19 @@ async def test_register_callback_by_manufacturer_id_and_address(
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{MANUFACTURER_ID: 76, ADDRESS: "44:44:33:11:23:45"},
{MANUFACTURER_ID: 21, ADDRESS: "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
)
assert len(mock_bleak_scanner_start.mock_calls) == 1
apple_device = BLEDevice("44:44:33:11:23:45", "apple")
apple_adv = AdvertisementData(
local_name="apple",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
rtx_device = BLEDevice("44:44:33:11:23:45", "rtx")
rtx_adv = AdvertisementData(
local_name="rtx",
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device, apple_adv)
inject_advertisement(hass, rtx_device, rtx_adv)
yale_device = BLEDevice("44:44:33:11:23:45", "apple")
yale_adv = AdvertisementData(
@@ -1426,7 +1476,7 @@ async def test_register_callback_by_manufacturer_id_and_address(
other_apple_device = BLEDevice("44:44:33:11:23:22", "apple")
other_apple_adv = AdvertisementData(
local_name="apple",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, other_apple_device, other_apple_adv)
@@ -1435,9 +1485,9 @@ async def test_register_callback_by_manufacturer_id_and_address(
assert len(callbacks) == 1
service_info: BluetoothServiceInfo = callbacks[0][0]
assert service_info.name == "apple"
assert service_info.manufacturer == "Apple, Inc."
assert service_info.manufacturer_id == 76
assert service_info.name == "rtx"
assert service_info.manufacturer == "RTX Telecom A/S"
assert service_info.manufacturer_id == 21
async def test_register_callback_by_service_uuid_and_address(
@@ -1603,31 +1653,31 @@ async def test_register_callback_by_local_name(
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{LOCAL_NAME: "apple"},
{LOCAL_NAME: "rtx"},
BluetoothScanningMode.ACTIVE,
)
assert len(mock_bleak_scanner_start.mock_calls) == 1
apple_device = BLEDevice("44:44:33:11:23:45", "apple")
apple_adv = AdvertisementData(
local_name="apple",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
rtx_device = BLEDevice("44:44:33:11:23:45", "rtx")
rtx_adv = AdvertisementData(
local_name="rtx",
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device, apple_adv)
inject_advertisement(hass, rtx_device, rtx_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty")
inject_advertisement(hass, empty_device, empty_adv)
apple_device_2 = BLEDevice("44:44:33:11:23:45", "apple")
apple_adv_2 = AdvertisementData(
local_name="apple2",
manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"},
rtx_device_2 = BLEDevice("44:44:33:11:23:45", "rtx")
rtx_adv_2 = AdvertisementData(
local_name="rtx2",
manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, apple_device_2, apple_adv_2)
inject_advertisement(hass, rtx_device_2, rtx_adv_2)
await hass.async_block_till_done()
@@ -1636,9 +1686,9 @@ async def test_register_callback_by_local_name(
assert len(callbacks) == 1
service_info: BluetoothServiceInfo = callbacks[0][0]
assert service_info.name == "apple"
assert service_info.manufacturer == "Apple, Inc."
assert service_info.manufacturer_id == 76
assert service_info.name == "rtx"
assert service_info.manufacturer == "RTX Telecom A/S"
assert service_info.manufacturer_id == 21
async def test_register_callback_by_local_name_overly_broad(
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tests for the BThome integration."""
"""Tests for the BTHome integration."""
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
+3 -3
View File
@@ -1,8 +1,8 @@
"""Test the BThome config flow."""
"""Test the BTHome config flow."""
from unittest.mock import patch
from bthome_ble import BThomeBluetoothDeviceData as DeviceData
from bthome_ble import BTHomeBluetoothDeviceData as DeviceData
from homeassistant import config_entries
from homeassistant.components.bluetooth import BluetoothChange
@@ -167,7 +167,7 @@ async def test_async_step_user_no_devices_found_2(hass):
"""
Test setup from service info cache with no devices found.
This variant tests with a non-BThome device known to us.
This variant tests with a non-BTHome device known to us.
"""
with patch(
"homeassistant.components.xiaomi_ble.config_flow.async_discovered_service_info",
+1 -1
View File
@@ -1,4 +1,4 @@
"""Test the BThome sensors."""
"""Test the BTHome sensors."""
from unittest.mock import patch
+2 -2
View File
@@ -14,7 +14,7 @@ NOT_GOVEE_SERVICE_INFO = BluetoothServiceInfo(
)
GVH5075_SERVICE_INFO = BluetoothServiceInfo(
name="GVH5075_2762",
name="GVH5075 2762",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
manufacturer_data={
@@ -26,7 +26,7 @@ GVH5075_SERVICE_INFO = BluetoothServiceInfo(
)
GVH5177_SERVICE_INFO = BluetoothServiceInfo(
name="GVH5177_2EC8",
name="GVH5177 2EC8",
address="4125DDBA-2774-4851-9889-6AADDD4CAC3D",
rssi=-56,
manufacturer_data={
@@ -27,7 +27,7 @@ async def test_async_step_bluetooth_valid_device(hass):
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "H5075_2762"
assert result2["title"] == "H5075 2762"
assert result2["data"] == {}
assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105"
@@ -73,7 +73,7 @@ async def test_async_step_user_with_found_devices(hass):
user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "H5177_2EC8"
assert result2["title"] == "H5177 2EC8"
assert result2["data"] == {}
assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D"
@@ -192,7 +192,7 @@ async def test_async_step_user_takes_precedence_over_discovery(hass):
user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "H5177_2EC8"
assert result2["title"] == "H5177 2EC8"
assert result2["data"] == {}
assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D"
+1 -1
View File
@@ -42,7 +42,7 @@ async def test_sensors(hass):
temp_sensor = hass.states.get("sensor.h5075_2762_temperature")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "21.34"
assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "H5075_2762 Temperature"
assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "H5075 2762 Temperature"
assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C"
assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
-17
View File
@@ -3,7 +3,6 @@ from unittest.mock import patch
from homeassistant import data_entry_flow
from homeassistant.components.iss.const import DOMAIN
from homeassistant.config import async_process_ha_core_config
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_SHOW_ON_MAP
from homeassistant.core import HomeAssistant
@@ -48,22 +47,6 @@ async def test_integration_already_exists(hass: HomeAssistant):
assert result.get("reason") == "single_instance_allowed"
async def test_abort_no_home(hass: HomeAssistant):
"""Test we don't create an entry if no coordinates are set."""
await async_process_ha_core_config(
hass,
{"latitude": 0.0, "longitude": 0.0},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={}
)
assert result.get("type") == data_entry_flow.FlowResultType.ABORT
assert result.get("reason") == "latitude_longitude_not_defined"
async def test_options(hass: HomeAssistant):
"""Test options flow."""
+24 -2
View File
@@ -22,10 +22,13 @@ DEFAULT_ENTRY_TITLE = LABEL
class MockMessage:
"""Mock a lifx message."""
def __init__(self):
def __init__(self, **kwargs):
"""Init message."""
self.target_addr = SERIAL
self.count = 9
for k, v in kwargs.items():
if k != "callb":
setattr(self, k, v)
class MockFailingLifxCommand:
@@ -50,15 +53,20 @@ class MockFailingLifxCommand:
class MockLifxCommand:
"""Mock a lifx command."""
def __name__(self):
"""Return name."""
return "mock_lifx_command"
def __init__(self, bulb, **kwargs):
"""Init command."""
self.bulb = bulb
self.calls = []
self.msg_kwargs = kwargs
def __call__(self, *args, **kwargs):
"""Call command."""
if callb := kwargs.get("callb"):
callb(self.bulb, MockMessage())
callb(self.bulb, MockMessage(**self.msg_kwargs))
self.calls.append([args, kwargs])
def reset_mock(self):
@@ -108,6 +116,20 @@ def _mocked_brightness_bulb() -> Light:
return bulb
def _mocked_clean_bulb() -> Light:
bulb = _mocked_bulb()
bulb.get_hev_cycle = MockLifxCommand(
bulb, duration=7200, remaining=0, last_power=False
)
bulb.hev_cycle = {
"duration": 7200,
"remaining": 30,
"last_power": False,
}
bulb.product = 90
return bulb
def _mocked_light_strip() -> Light:
bulb = _mocked_bulb()
bulb.product = 31 # LIFX Z
-1
View File
@@ -1,5 +1,4 @@
"""Tests for the lifx integration."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -0,0 +1,74 @@
"""Test the lifx binary sensor platwform."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components import lifx
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
CONF_HOST,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import EntityCategory
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import (
DEFAULT_ENTRY_TITLE,
IP_ADDRESS,
MAC_ADDRESS,
SERIAL,
_mocked_clean_bulb,
_patch_config_flow_try_connect,
_patch_device,
_patch_discovery,
)
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_hev_cycle_state(hass: HomeAssistant) -> None:
"""Test HEV cycle state binary sensor."""
config_entry = MockConfigEntry(
domain=lifx.DOMAIN,
title=DEFAULT_ENTRY_TITLE,
data={CONF_HOST: IP_ADDRESS},
unique_id=MAC_ADDRESS,
)
config_entry.add_to_hass(hass)
bulb = _mocked_clean_bulb()
with _patch_discovery(device=bulb), _patch_config_flow_try_connect(
device=bulb
), _patch_device(device=bulb):
await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}})
await hass.async_block_till_done()
entity_id = "binary_sensor.my_bulb_clean_cycle"
entity_registry = er.async_get(hass)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.RUNNING
entry = entity_registry.async_get(entity_id)
assert state
assert entry.unique_id == f"{SERIAL}_hev_cycle_state"
assert entry.entity_category == EntityCategory.DIAGNOSTIC
bulb.hev_cycle = {"duration": 7200, "remaining": 0, "last_power": False}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
bulb.hev_cycle = None
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNKNOWN
+11
View File
@@ -1,4 +1,8 @@
"""Tests for button platform."""
from unittest.mock import patch
import pytest
from homeassistant.components import lifx
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.lifx.const import DOMAIN
@@ -21,6 +25,13 @@ from . import (
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_lifx_coordinator_sleep():
"""Mock out lifx coordinator sleeps."""
with patch("homeassistant.components.lifx.coordinator.LIFX_IDENTIFY_DELAY", 0):
yield
async def test_button_restart(hass: HomeAssistant) -> None:
"""Test that a bulb can be restarted."""
config_entry = MockConfigEntry(
+7 -1
View File
@@ -50,6 +50,13 @@ from . import (
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture(autouse=True)
def patch_lifx_state_settle_delay():
"""Set asyncio.sleep for state settles to zero."""
with patch("homeassistant.components.lifx.light.LIFX_STATE_SETTLE_DELAY", 0):
yield
async def test_light_unique_id(hass: HomeAssistant) -> None:
"""Test a light unique id."""
already_migrated_config_entry = MockConfigEntry(
@@ -98,7 +105,6 @@ async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None:
assert device.identifiers == {(DOMAIN, SERIAL)}
@patch("homeassistant.components.lifx.light.COLOR_ZONE_POPULATE_DELAY", 0)
async def test_light_strip(hass: HomeAssistant) -> None:
"""Test a light strip."""
already_migrated_config_entry = MockConfigEntry(
+2 -2
View File
@@ -99,8 +99,8 @@ async def setup_integration(
with patch(
"homeassistant.components.litterrobot.hub.Account", return_value=mock_account
), patch(
"homeassistant.components.litterrobot.PLATFORMS",
[platform_domain] if platform_domain else [],
"homeassistant.components.litterrobot.PLATFORMS_BY_TYPE",
{Robot: (platform_domain,)} if platform_domain else {},
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+8 -1
View File
@@ -52,6 +52,7 @@ async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None:
assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID_OLD
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
assert len(ent_reg.entities) == 1
assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE)
vacuum = hass.states.get(VACUUM_ENTITY_ID)
@@ -78,10 +79,16 @@ async def test_no_robots(
hass: HomeAssistant, mock_account_with_no_robots: MagicMock
) -> None:
"""Tests the vacuum entity was set up."""
await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN)
entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN)
assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE)
ent_reg = er.async_get(hass)
assert len(ent_reg.entities) == 0
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_vacuum_with_error(
hass: HomeAssistant, mock_account_with_error: MagicMock
@@ -4,7 +4,9 @@ from datetime import datetime, timedelta
from ipaddress import IPv4Address
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
import pytest
import pytz
from pyunifiprotect.data import (
Bootstrap,
Camera,
@@ -28,6 +30,7 @@ from homeassistant.components.unifiprotect.media_source import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .conftest import MockUFPFixture
from .utils import init_entry
@@ -430,13 +433,52 @@ async def test_browse_media_event_type(
assert browse.children[3].identifier == "test_id:browse:all:smart"
ONE_MONTH_SIMPLE = (
datetime(
year=2022,
month=9,
day=1,
hour=3,
minute=0,
second=0,
microsecond=0,
tzinfo=pytz.timezone("US/Pacific"),
),
1,
)
TWO_MONTH_SIMPLE = (
datetime(
year=2022,
month=8,
day=31,
hour=3,
minute=0,
second=0,
microsecond=0,
tzinfo=pytz.timezone("US/Pacific"),
),
2,
)
@pytest.mark.parametrize(
"start,months",
[ONE_MONTH_SIMPLE, TWO_MONTH_SIMPLE],
)
@freeze_time("2022-09-15 03:00:00-07:00")
async def test_browse_media_time(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
start: datetime,
months: int,
):
"""Test browsing time selector level media."""
last_month = fixed_now.replace(day=1) - timedelta(days=1)
ufp.api.bootstrap._recording_start = last_month
end = datetime.fromisoformat("2022-09-15 03:00:00-07:00")
end_local = dt_util.as_local(end)
ufp.api.bootstrap._recording_start = dt_util.as_utc(start)
ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap)
await init_entry(hass, ufp, [doorbell], regenerate_ids=False)
@@ -449,17 +491,89 @@ async def test_browse_media_time(
assert browse.title == f"UnifiProtect > {doorbell.name} > All Events"
assert browse.identifier == base_id
assert len(browse.children) == 4
assert len(browse.children) == 3 + months
assert browse.children[0].title == "Last 24 Hours"
assert browse.children[0].identifier == f"{base_id}:recent:1"
assert browse.children[1].title == "Last 7 Days"
assert browse.children[1].identifier == f"{base_id}:recent:7"
assert browse.children[2].title == "Last 30 Days"
assert browse.children[2].identifier == f"{base_id}:recent:30"
assert browse.children[3].title == f"{fixed_now.strftime('%B %Y')}"
assert browse.children[3].title == f"{end_local.strftime('%B %Y')}"
assert (
browse.children[3].identifier
== f"{base_id}:range:{fixed_now.year}:{fixed_now.month}"
== f"{base_id}:range:{end_local.year}:{end_local.month}"
)
ONE_MONTH_TIMEZONE = (
datetime(
year=2022,
month=8,
day=1,
hour=3,
minute=0,
second=0,
microsecond=0,
tzinfo=pytz.timezone("US/Pacific"),
),
1,
)
TWO_MONTH_TIMEZONE = (
datetime(
year=2022,
month=7,
day=31,
hour=21,
minute=0,
second=0,
microsecond=0,
tzinfo=pytz.timezone("US/Pacific"),
),
2,
)
@pytest.mark.parametrize(
"start,months",
[ONE_MONTH_TIMEZONE, TWO_MONTH_TIMEZONE],
)
@freeze_time("2022-08-31 21:00:00-07:00")
async def test_browse_media_time_timezone(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
start: datetime,
months: int,
):
"""Test browsing time selector level media."""
end = datetime.fromisoformat("2022-08-31 21:00:00-07:00")
end_local = dt_util.as_local(end)
ufp.api.bootstrap._recording_start = dt_util.as_utc(start)
ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap)
await init_entry(hass, ufp, [doorbell], regenerate_ids=False)
base_id = f"test_id:browse:{doorbell.id}:all"
source = await async_get_media_source(hass)
media_item = MediaSourceItem(hass, DOMAIN, base_id, None)
browse = await source.async_browse_media(media_item)
assert browse.title == f"UnifiProtect > {doorbell.name} > All Events"
assert browse.identifier == base_id
assert len(browse.children) == 3 + months
assert browse.children[0].title == "Last 24 Hours"
assert browse.children[0].identifier == f"{base_id}:recent:1"
assert browse.children[1].title == "Last 7 Days"
assert browse.children[1].identifier == f"{base_id}:recent:7"
assert browse.children[2].title == "Last 30 Days"
assert browse.children[2].identifier == f"{base_id}:recent:30"
assert browse.children[3].title == f"{end_local.strftime('%B %Y')}"
assert (
browse.children[3].identifier
== f"{base_id}:range:{end_local.year}:{end_local.month}"
)
@@ -599,13 +713,14 @@ async def test_browse_media_eventthumb(
assert browse.media_class == MEDIA_CLASS_IMAGE
@freeze_time("2022-09-15 03:00:00-07:00")
async def test_browse_media_day(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime
):
"""Test browsing day selector level media."""
last_month = fixed_now.replace(day=1) - timedelta(days=1)
ufp.api.bootstrap._recording_start = last_month
start = datetime.fromisoformat("2022-09-03 03:00:00-07:00")
ufp.api.bootstrap._recording_start = dt_util.as_utc(start)
ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap)
await init_entry(hass, ufp, [doorbell], regenerate_ids=False)
@@ -623,7 +738,7 @@ async def test_browse_media_day(
== f"UnifiProtect > {doorbell.name} > All Events > {fixed_now.strftime('%B %Y')}"
)
assert browse.identifier == base_id
assert len(browse.children) in (29, 30, 31, 32)
assert len(browse.children) == 14
assert browse.children[0].title == "Whole Month"
assert browse.children[0].identifier == f"{base_id}:all"
+65 -75
View File
@@ -24,6 +24,31 @@ from homeassistant.util import datetime as dt_util
from tests.common import async_fire_time_changed
UPDATE_ENTITY = "update.z_wave_thermostat_firmware"
FIRMWARE_UPDATES = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
async def test_update_entity_success(
@@ -60,31 +85,7 @@ async def test_update_entity_success(
result = await ws_client.receive_json()
assert result["result"] is None
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2))
await hass.async_block_till_done()
@@ -171,31 +172,7 @@ async def test_update_entity_failure(
hass_ws_client,
):
"""Test update entity failed install."""
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
@@ -228,31 +205,7 @@ async def test_update_entity_sleep(
multisensor_6.receive_event(event)
client.async_send_command.reset_mock()
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
@@ -273,3 +226,40 @@ async def test_update_entity_sleep(
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == multisensor_6.node_id
async def test_update_entity_dead(
hass,
client,
multisensor_6,
integration,
):
"""Test update occurs when device is dead after it becomes alive."""
event = Event(
"dead",
data={"source": "node", "event": "dead", "nodeId": multisensor_6.node_id},
)
multisensor_6.receive_event(event)
client.async_send_command.reset_mock()
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
# Because node is asleep we shouldn't attempt to check for firmware updates
assert len(client.async_send_command.call_args_list) == 0
event = Event(
"alive",
data={"source": "node", "event": "alive", "nodeId": multisensor_6.node_id},
)
multisensor_6.receive_event(event)
await hass.async_block_till_done()
# Now that the node is up we can check for updates
assert len(client.async_send_command.call_args_list) > 0
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == multisensor_6.node_id