Compare commits

..

52 Commits

Author SHA1 Message Date
Paulus Schoutsen c8ad8a6d86 Bumped version to 2022.9.0b6 2022-09-06 12:55:44 -04:00
Bram Kragten 9155f669e9 Update frontend to 20220906.0 (#77910) 2022-09-06 12:55:37 -04:00
J. Nick Koston e1e153f391 Bump bluetooth-auto-recovery to 0.3.1 (#77898) 2022-09-06 12:55:36 -04:00
Artem Draft 1dbcf88e15 Bump pybravia to 0.2.2 (#77867) 2022-09-06 12:55:35 -04:00
Raman Gupta a13438c5b0 Improve performance impact of zwave_js update entity and other tweaks (#77866)
* Improve performance impact of zwave_js update entity and other tweaks

* Reduce concurrent polls

* we need to write state after setting in progress to false

* Fix existing tests

* Fix tests by fixing fixtures

* remove redundant conditional

* Add test for delayed startup

* tweaks

* outdent happy path

* Add missing PROGRESS feature support

* Update homeassistant/components/zwave_js/update.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/update.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix tests by reverting outdent, PR comments, mark callback

* Remove redundant conditional

* make more readable

* Remove unused SCAN_INTERVAL

* Catch FailedZWaveCommand

* Add comment and remove poll unsub on update

* Fix catching error and add test

* readability

* Fix tests

* Add assertions

* rely on built in progress indicator

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-09-06 12:55:35 -04:00
J. Nick Koston d98687b789 Bump thermopro-ble to 0.4.3 (#77863)
* Bump thermopro-ble to 0.4.2

- Turns on rounding of long values
- Uses bluetooth-data-tools under the hood
- Adds the TP393 since it works without any changes to the parser

Changelog: https://github.com/Bluetooth-Devices/thermopro-ble/compare/v0.4.0...v0.4.2

* bump again for device detection fix
2022-09-06 12:55:34 -04:00
Marc Mueller 319b0b8902 Pin astroid to fix pylint (#77862) 2022-09-06 12:55:33 -04:00
J. Nick Koston 62dcbc4d4a Add RSSI to the bluetooth debug log (#77860) 2022-09-06 12:55:33 -04:00
J. Nick Koston 6989b16274 Bump zeroconf to 0.39.1 (#77859) 2022-09-06 12:55:32 -04:00
J. Nick Koston 31d085cdf8 Fix history stats device class when type is not time (#77855) 2022-09-06 12:55:31 -04:00
Oliver Völker 61ee621c90 Adjust Renault default scan interval (#77823)
raise DEFAULT_SCAN_INTERVAL to 7 minutes

This PR is raising the default scan interval for the Renault API from 5 minutes to 7 minutes. Lower intervals fail sometimes, maybe due to quota limitations. This seems to be a working interval as described in home-assistant#73220
2022-09-06 12:55:30 -04:00
Yevhenii Vaskivskyi f5e61ecdec Handle exception on projector being unavailable (#77802) 2022-09-06 12:55:30 -04:00
G Johansson 2bfcdc66b6 Allow empty db in SQL options flow (#77777) 2022-09-06 12:55:29 -04:00
Martin Hjelmare 3240f8f938 Refactor zwave_js event handling (#77732)
* Refactor zwave_js event handling

* Clean up
2022-09-06 12:55:28 -04:00
Steven Looman 74ddc336ca Use identifiers host and serial number to match device (#75657) 2022-09-06 12:55:28 -04:00
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
156 changed files with 4200 additions and 2884 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)
+19 -2
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:
@@ -311,12 +327,13 @@ class BluetoothManager:
matched_domains = self._integration_matcher.match_domains(service_info)
_LOGGER.debug(
"%s: %s %s connectable: %s match: %s",
"%s: %s %s connectable: %s match: %s rssi: %s",
source,
address,
advertisement_data,
connectable,
matched_domains,
device.rssi,
)
for match in self._callback_index.match_callbacks(service_info):
@@ -6,8 +6,8 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.16.0",
"bluetooth-adapters==0.3.3",
"bluetooth-auto-recovery==0.3.0"
"bluetooth-adapters==0.3.4",
"bluetooth-auto-recovery==0.3.1"
],
"codeowners": ["@bdraco"],
"config_flow": true,
@@ -7,7 +7,13 @@ from functools import wraps
import logging
from typing import Any, Final, TypeVar
from pybravia import BraviaTV, BraviaTVError, BraviaTVNotFound
from pybravia import (
BraviaTV,
BraviaTVConnectionError,
BraviaTVConnectionTimeout,
BraviaTVError,
BraviaTVNotFound,
)
from typing_extensions import Concatenate, ParamSpec
from homeassistant.components.media_player.const import (
@@ -130,6 +136,10 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
_LOGGER.debug("Update skipped, Bravia API service is reloading")
return
raise UpdateFailed("Error communicating with device") from err
except (BraviaTVConnectionError, BraviaTVConnectionTimeout):
self.is_on = False
self.connected = False
_LOGGER.debug("Update skipped, Bravia TV is off")
except BraviaTVError as err:
self.is_on = False
self.connected = False
@@ -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.2"],
"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"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Epson",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/epson",
"requirements": ["epson-projector==0.4.6"],
"requirements": ["epson-projector==0.5.0"],
"codeowners": ["@pszafer"],
"iot_class": "local_polling",
"loggers": ["epson_projector"]
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from epson_projector import Projector
from epson_projector import Projector, ProjectorUnavailableError
from epson_projector.const import (
BACK,
BUSY,
@@ -20,7 +20,6 @@ from epson_projector.const import (
POWER,
SOURCE,
SOURCE_LIST,
STATE_UNAVAILABLE as EPSON_STATE_UNAVAILABLE,
TURN_OFF,
TURN_ON,
VOL_DOWN,
@@ -123,11 +122,16 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
async def async_update(self) -> None:
"""Update state of device."""
power_state = await self._projector.get_power()
_LOGGER.debug("Projector status: %s", power_state)
if not power_state or power_state == EPSON_STATE_UNAVAILABLE:
try:
power_state = await self._projector.get_power()
except ProjectorUnavailableError as ex:
_LOGGER.debug("Projector is unavailable: %s", ex)
self._attr_available = False
return
if not power_state:
self._attr_available = False
return
_LOGGER.debug("Projector status: %s", power_state)
self._attr_available = True
if power_state == EPSON_CODES[POWER]:
self._attr_state = STATE_ON
@@ -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==20220906.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"
@@ -143,7 +143,6 @@ class HistoryStatsSensorBase(
class HistoryStatsSensor(HistoryStatsSensorBase):
"""A HistoryStats sensor."""
_attr_device_class = SensorDeviceClass.DURATION
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
@@ -157,6 +156,8 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
self._attr_native_unit_of_measurement = UNITS[sensor_type]
self._type = sensor_type
self._process_update()
if self._type == CONF_TYPE_TIME:
self._attr_device_class = SensorDeviceClass.DURATION
@callback
def _process_update(self) -> None:
+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
+1 -1
View File
@@ -6,7 +6,7 @@ DOMAIN = "renault"
CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
DEFAULT_SCAN_INTERVAL = 300 # 5 minutes
DEFAULT_SCAN_INTERVAL = 420 # 7 minutes
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -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),
+5 -2
View File
@@ -147,9 +147,12 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
) -> FlowResult:
"""Manage SQL options."""
errors = {}
db_url_default = DEFAULT_URL.format(
hass_config_path=self.hass.config.path(DEFAULT_DB_FILE)
)
if user_input is not None:
db_url = user_input[CONF_DB_URL]
db_url = user_input.get(CONF_DB_URL, db_url_default)
query = user_input[CONF_QUERY]
column = user_input[CONF_COLUMN_NAME]
@@ -176,7 +179,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
vol.Optional(
CONF_DB_URL,
description={
"suggested_value": self.entry.options[CONF_DB_URL]
@@ -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()
@@ -3,9 +3,12 @@
"name": "ThermoPro",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/thermopro",
"bluetooth": [{ "local_name": "TP35*", "connectable": false }],
"bluetooth": [
{ "local_name": "TP35*", "connectable": false },
{ "local_name": "TP39*", "connectable": false }
],
"dependencies": ["bluetooth"],
"requirements": ["thermopro-ble==0.4.0"],
"requirements": ["thermopro-ble==0.4.3"],
"codeowners": ["@bdraco"],
"iot_class": "local_push"
}
@@ -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)
+19 -10
View File
@@ -25,15 +25,18 @@ from homeassistant.helpers.update_coordinator import (
)
from .const import (
CONFIG_ENTRY_HOST,
CONFIG_ENTRY_MAC_ADDRESS,
CONFIG_ENTRY_ORIGINAL_UDN,
CONFIG_ENTRY_ST,
CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
IDENTIFIER_HOST,
IDENTIFIER_SERIAL_NUMBER,
LOGGER,
)
from .device import Device, async_create_device, async_get_mac_address_from_host
from .device import Device, async_create_device
NOTIFICATION_ID = "upnp_notification"
NOTIFICATION_TITLE = "UPnP/IGD Setup"
@@ -106,24 +109,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device.original_udn = entry.data[CONFIG_ENTRY_ORIGINAL_UDN]
# Store mac address for changed UDN matching.
if device.host:
device.mac_address = await async_get_mac_address_from_host(hass, device.host)
if device.mac_address and not entry.data.get("CONFIG_ENTRY_MAC_ADDRESS"):
device_mac_address = await device.async_get_mac_address()
if device_mac_address and not entry.data.get(CONFIG_ENTRY_MAC_ADDRESS):
hass.config_entries.async_update_entry(
entry=entry,
data={
**entry.data,
CONFIG_ENTRY_MAC_ADDRESS: device.mac_address,
CONFIG_ENTRY_MAC_ADDRESS: device_mac_address,
CONFIG_ENTRY_HOST: device.host,
},
)
identifiers = {(DOMAIN, device.usn)}
if device.host:
identifiers.add((IDENTIFIER_HOST, device.host))
if device.serial_number:
identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number))
connections = {(dr.CONNECTION_UPNP, device.udn)}
if device.mac_address:
connections.add((dr.CONNECTION_NETWORK_MAC, device.mac_address))
if device_mac_address:
connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address))
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_device(
identifiers=set(), connections=connections
identifiers=identifiers, connections=connections
)
if device_entry:
LOGGER.debug(
@@ -136,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=connections,
identifiers={(DOMAIN, device.usn)},
identifiers=identifiers,
name=device.name,
manufacturer=device.manufacturer,
model=device.model_name,
@@ -148,7 +157,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Update identifier.
device_entry = device_registry.async_update_device(
device_entry.id,
new_identifiers={(DOMAIN, device.usn)},
new_identifiers=identifiers,
)
assert device_entry
+6 -2
View File
@@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from .const import (
CONFIG_ENTRY_HOST,
CONFIG_ENTRY_LOCATION,
CONFIG_ENTRY_MAC_ADDRESS,
CONFIG_ENTRY_ORIGINAL_UDN,
@@ -161,22 +162,25 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
unique_id = discovery_info.ssdp_usn
await self.async_set_unique_id(unique_id)
mac_address = await _async_mac_address_from_discovery(self.hass, discovery_info)
host = discovery_info.ssdp_headers["_host"]
self._abort_if_unique_id_configured(
# Store mac address for older entries.
# The location is stored in the config entry such that when the location changes, the entry is reloaded.
updates={
CONFIG_ENTRY_MAC_ADDRESS: mac_address,
CONFIG_ENTRY_LOCATION: discovery_info.ssdp_location,
CONFIG_ENTRY_HOST: host,
},
)
# Handle devices changing their UDN, only allow a single host.
for entry in self._async_current_entries(include_ignore=True):
entry_mac_address = entry.data.get(CONFIG_ENTRY_MAC_ADDRESS)
entry_st = entry.data.get(CONFIG_ENTRY_ST)
if entry_mac_address != mac_address:
entry_host = entry.data.get(CONFIG_ENTRY_HOST)
if entry_mac_address != mac_address and entry_host != host:
continue
entry_st = entry.data.get(CONFIG_ENTRY_ST)
if discovery_info.ssdp_st != entry_st:
# Check ssdp_st to prevent swapping between IGDv1 and IGDv2.
continue
+3 -2
View File
@@ -6,7 +6,6 @@ from homeassistant.const import TIME_SECONDS
LOGGER = logging.getLogger(__package__)
CONF_LOCAL_IP = "local_ip"
DOMAIN = "upnp"
BYTES_RECEIVED = "bytes_received"
BYTES_SENT = "bytes_sent"
@@ -24,7 +23,9 @@ CONFIG_ENTRY_UDN = "udn"
CONFIG_ENTRY_ORIGINAL_UDN = "original_udn"
CONFIG_ENTRY_MAC_ADDRESS = "mac_address"
CONFIG_ENTRY_LOCATION = "location"
CONFIG_ENTRY_HOST = "host"
IDENTIFIER_HOST = "upnp_host"
IDENTIFIER_SERIAL_NUMBER = "upnp_serial_number"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds()
ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2"
SSDP_SEARCH_TIMEOUT = 4
+7 -1
View File
@@ -69,9 +69,15 @@ class Device:
self.hass = hass
self._igd_device = igd_device
self.coordinator: DataUpdateCoordinator | None = None
self.mac_address: str | None = None
self.original_udn: str | None = None
async def async_get_mac_address(self) -> str | None:
"""Get mac address."""
if not self.host:
return None
return await async_get_mac_address_from_host(self.hass, self.host)
@property
def udn(self) -> str:
"""Get the UDN."""
@@ -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",
@@ -2,7 +2,7 @@
"domain": "zeroconf",
"name": "Zero-configuration networking (zeroconf)",
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
"requirements": ["zeroconf==0.39.0"],
"requirements": ["zeroconf==0.39.1"],
"dependencies": ["network", "api"],
"codeowners": ["@bdraco"],
"quality_scale": "internal",
+347 -247
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Coroutine
from typing import Any
from async_timeout import timeout
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import CommandClass
from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node as ZwaveNode
@@ -79,7 +80,6 @@ from .const import (
CONF_USB_PATH,
CONF_USE_ADDON,
DATA_CLIENT,
DATA_PLATFORM_SETUP,
DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER,
@@ -104,7 +104,8 @@ from .services import ZWaveServices
CONNECT_TIMEOUT = 10
DATA_CLIENT_LISTEN_TASK = "client_listen_task"
DATA_START_PLATFORM_TASK = "start_platform_task"
DATA_DRIVER_EVENTS = "driver_events"
DATA_START_CLIENT_TASK = "start_client_task"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -118,51 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
@callback
def register_node_in_dev_reg(
hass: HomeAssistant,
entry: ConfigEntry,
dev_reg: device_registry.DeviceRegistry,
driver: Driver,
node: ZwaveNode,
remove_device_func: Callable[[device_registry.DeviceEntry], None],
) -> device_registry.DeviceEntry:
"""Register node in dev reg."""
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
device = dev_reg.async_get_device({device_id})
# Replace the device if it can be determined that this node is not the
# same product as it was previously.
if (
device_id_ext
and device
and len(device.identifiers) == 2
and device_id_ext not in device.identifiers
):
remove_device_func(device)
device = None
if device_id_ext:
ids = {device_id, device_id_ext}
else:
ids = {device_id}
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers=ids,
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 UNDEFINED,
)
async_dispatcher_send(hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
return device
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Z-Wave JS from a config entry."""
if use_addon := entry.data.get(CONF_USE_ADDON):
@@ -191,37 +147,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Set up websocket API
async_register_api(hass)
platform_task = hass.async_create_task(start_platforms(hass, entry, client))
# Create a task to allow the config entry to be unloaded before the driver is ready.
# Unloading the config entry is needed if the client listen task errors.
start_client_task = hass.async_create_task(start_client(hass, entry, client))
hass.data[DOMAIN].setdefault(entry.entry_id, {})[
DATA_START_PLATFORM_TASK
] = platform_task
DATA_START_CLIENT_TASK
] = start_client_task
return True
async def start_platforms(
async def start_client(
hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient
) -> None:
"""Start platforms and perform discovery."""
"""Start listening with the client."""
entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {})
entry_hass_data[DATA_CLIENT] = client
entry_hass_data[DATA_PLATFORM_SETUP] = {}
driver_ready = asyncio.Event()
driver_events = entry_hass_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry)
async def handle_ha_shutdown(event: Event) -> None:
"""Handle HA shutdown."""
await disconnect_client(hass, entry)
listen_task = asyncio.create_task(client_listen(hass, entry, client, driver_ready))
listen_task = asyncio.create_task(
client_listen(hass, entry, client, driver_events.ready)
)
entry_hass_data[DATA_CLIENT_LISTEN_TASK] = listen_task
entry.async_on_unload(
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown)
)
try:
await driver_ready.wait()
await driver_events.ready.wait()
except asyncio.CancelledError:
LOGGER.debug("Cancelling start platforms")
LOGGER.debug("Cancelling start client")
return
LOGGER.info("Connection to Zwave JS Server initialized")
@@ -229,37 +188,297 @@ async def start_platforms(
if client.driver is None:
raise RuntimeError("Driver not ready.")
await setup_driver(hass, entry, client, client.driver)
await driver_events.setup(client.driver)
async def setup_driver( # noqa: C901
hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient, driver: Driver
) -> None:
"""Set up devices using the ready driver."""
dev_reg = device_registry.async_get(hass)
ent_reg = entity_registry.async_get(hass)
entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {})
platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP]
registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict)
discovered_value_ids: dict[str, set[str]] = defaultdict(set)
class DriverEvents:
"""Represent driver events."""
async def async_setup_platform(platform: Platform) -> None:
"""Set up platform if needed."""
if platform not in platform_setup_tasks:
platform_setup_tasks[platform] = hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
driver: Driver
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Set up the driver events instance."""
self.config_entry = entry
self.dev_reg = device_registry.async_get(hass)
self.hass = hass
self.platform_setup_tasks: dict[str, asyncio.Task] = {}
self.ready = asyncio.Event()
# Make sure to not pass self to ControllerEvents until all attributes are set.
self.controller_events = ControllerEvents(hass, self)
async def setup(self, driver: Driver) -> None:
"""Set up devices using the ready driver."""
self.driver = driver
# If opt in preference hasn't been specified yet, we do nothing, otherwise
# we apply the preference
if opted_in := self.config_entry.data.get(CONF_DATA_COLLECTION_OPTED_IN):
await async_enable_statistics(driver)
elif opted_in is False:
await driver.async_disable_statistics()
# Check for nodes that no longer exist and remove them
stored_devices = device_registry.async_entries_for_config_entry(
self.dev_reg, self.config_entry.entry_id
)
known_devices = [
self.dev_reg.async_get_device({get_device_id(driver, node)})
for node in driver.controller.nodes.values()
]
# Devices that are in the device registry that are not known by the controller can be removed
for device in stored_devices:
if device not in known_devices:
self.dev_reg.async_remove_device(device.id)
# run discovery on all ready nodes
await asyncio.gather(
*(
self.controller_events.async_on_node_added(node)
for node in driver.controller.nodes.values()
)
await platform_setup_tasks[platform]
)
# listen for new nodes being added to the mesh
self.config_entry.async_on_unload(
driver.controller.on(
"node added",
lambda event: self.hass.async_create_task(
self.controller_events.async_on_node_added(event["node"])
),
)
)
# listen for nodes being removed from the mesh
# NOTE: This will not remove nodes that were removed when HA was not running
self.config_entry.async_on_unload(
driver.controller.on(
"node removed", self.controller_events.async_on_node_removed
)
)
async def async_setup_platform(self, platform: Platform) -> None:
"""Set up platform if needed."""
if platform not in self.platform_setup_tasks:
self.platform_setup_tasks[platform] = self.hass.async_create_task(
self.hass.config_entries.async_forward_entry_setup(
self.config_entry, platform
)
)
await self.platform_setup_tasks[platform]
class ControllerEvents:
"""Represent controller events.
Handle the following events:
- node added
- node removed
"""
def __init__(self, hass: HomeAssistant, driver_events: DriverEvents) -> None:
"""Set up the controller events instance."""
self.hass = hass
self.config_entry = driver_events.config_entry
self.discovered_value_ids: dict[str, set[str]] = defaultdict(set)
self.driver_events = driver_events
self.dev_reg = driver_events.dev_reg
self.registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict)
self.node_events = NodeEvents(hass, self)
@callback
def remove_device(device: device_registry.DeviceEntry) -> None:
def remove_device(self, device: device_registry.DeviceEntry) -> None:
"""Remove device from registry."""
# note: removal of entity registry entry is handled by core
dev_reg.async_remove_device(device.id)
registered_unique_ids.pop(device.id, None)
discovered_value_ids.pop(device.id, None)
self.dev_reg.async_remove_device(device.id)
self.registered_unique_ids.pop(device.id, None)
self.discovered_value_ids.pop(device.id, None)
async def async_on_node_added(self, node: ZwaveNode) -> None:
"""Handle node added event."""
# No need for a ping button or node status sensor for controller nodes
if not node.is_controller_node:
# Create a node status sensor for each device
await self.driver_events.async_setup_platform(Platform.SENSOR)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_node_status_sensor",
node,
)
# Create a ping button for each device
await self.driver_events.async_setup_platform(Platform.BUTTON)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_ping_button_entity",
node,
)
# we only want to run discovery when the node has reached ready state,
# otherwise we'll have all kinds of missing info issues.
if node.ready:
await self.node_events.async_on_node_ready(node)
return
# if node is not yet ready, register one-time callback for ready state
LOGGER.debug("Node added: %s - waiting for it to become ready", node.node_id)
node.once(
"ready",
lambda event: self.hass.async_create_task(
self.node_events.async_on_node_ready(event["node"])
),
)
# we do submit the node to device registry so user has
# some visual feedback that something is (in the process of) being added
self.register_node_in_dev_reg(node)
@callback
def async_on_node_removed(self, event: dict) -> None:
"""Handle node removed event."""
node: ZwaveNode = event["node"]
replaced: bool = event.get("replaced", False)
# grab device in device registry attached to this node
dev_id = get_device_id(self.driver_events.driver, node)
device = self.dev_reg.async_get_device({dev_id})
# We assert because we know the device exists
assert device
if replaced:
self.discovered_value_ids.pop(device.id, None)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{get_valueless_base_unique_id(self.driver_events.driver, node)}_remove_entity",
)
else:
self.remove_device(device)
@callback
def register_node_in_dev_reg(self, node: ZwaveNode) -> device_registry.DeviceEntry:
"""Register node in dev reg."""
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
device = self.dev_reg.async_get_device({device_id})
# Replace the device if it can be determined that this node is not the
# same product as it was previously.
if (
device_id_ext
and device
and len(device.identifiers) == 2
and device_id_ext not in device.identifiers
):
self.remove_device(device)
device = None
if device_id_ext:
ids = {device_id, device_id_ext}
else:
ids = {device_id}
device = self.dev_reg.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers=ids,
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 UNDEFINED,
)
async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
return device
class NodeEvents:
"""Represent node events.
Handle the following events:
- ready
- value added
- value updated
- metadata updated
- value notification
- notification
"""
def __init__(
self, hass: HomeAssistant, controller_events: ControllerEvents
) -> None:
"""Set up the node events instance."""
self.config_entry = controller_events.config_entry
self.controller_events = controller_events
self.dev_reg = controller_events.dev_reg
self.ent_reg = entity_registry.async_get(hass)
self.hass = hass
async def async_on_node_ready(self, node: ZwaveNode) -> None:
"""Handle node ready event."""
LOGGER.debug("Processing node %s", node)
# register (or update) node in device registry
device = self.controller_events.register_node_in_dev_reg(node)
# We only want to create the defaultdict once, even on reinterviews
if device.id not in self.controller_events.registered_unique_ids:
self.controller_events.registered_unique_ids[device.id] = defaultdict(set)
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}
# run discovery on all node values and create/update entities
await asyncio.gather(
*(
self.async_handle_discovery_info(
device, disc_info, value_updates_disc_info
)
for disc_info in async_discover_node_values(
node, device, self.controller_events.discovered_value_ids
)
)
)
# add listeners to handle new values that get added later
for event in ("value added", "value updated", "metadata updated"):
self.config_entry.async_on_unload(
node.on(
event,
lambda event: self.hass.async_create_task(
self.async_on_value_added(
value_updates_disc_info, event["value"]
)
),
)
)
# add listener for stateless node value notification events
self.config_entry.async_on_unload(
node.on(
"value notification",
lambda event: self.async_on_value_notification(
event["value_notification"]
),
)
)
# add listener for stateless node notification events
self.config_entry.async_on_unload(
node.on("notification", self.async_on_notification)
)
# Create a firmware update entity for each non-controller device that
# supports firmware updates
if not node.is_controller_node and any(
CommandClass.FIRMWARE_UPDATE_MD.value == cc.id
for cc in node.command_classes
):
await self.controller_events.driver_events.async_setup_platform(
Platform.UPDATE
)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_firmware_update_entity",
node,
)
async def async_handle_discovery_info(
self,
device: device_registry.DeviceEntry,
disc_info: ZwaveDiscoveryInfo,
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo],
@@ -269,20 +488,22 @@ async def setup_driver( # noqa: C901
# the value_id format. Some time in the future, this call (as well as the
# helper functions) can be removed.
async_migrate_discovered_value(
hass,
ent_reg,
registered_unique_ids[device.id][disc_info.platform],
self.hass,
self.ent_reg,
self.controller_events.registered_unique_ids[device.id][disc_info.platform],
device,
driver,
self.controller_events.driver_events.driver,
disc_info,
)
platform = disc_info.platform
await async_setup_platform(platform)
await self.controller_events.driver_events.async_setup_platform(platform)
LOGGER.debug("Discovered entity: %s", disc_info)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_{platform}",
disc_info,
)
# If we don't need to watch for updates return early
@@ -294,151 +515,57 @@ async def setup_driver( # noqa: C901
if len(value_updates_disc_info) != 1:
return
# add listener for value updated events
entry.async_on_unload(
self.config_entry.async_on_unload(
disc_info.node.on(
"value updated",
lambda event: async_on_value_updated_fire_event(
lambda event: self.async_on_value_updated_fire_event(
value_updates_disc_info, event["value"]
),
)
)
async def async_on_node_ready(node: ZwaveNode) -> None:
"""Handle node ready event."""
LOGGER.debug("Processing node %s", node)
# register (or update) node in device registry
device = register_node_in_dev_reg(
hass, entry, dev_reg, driver, node, remove_device
)
# We only want to create the defaultdict once, even on reinterviews
if device.id not in registered_unique_ids:
registered_unique_ids[device.id] = defaultdict(set)
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}
# run discovery on all node values and create/update entities
await asyncio.gather(
*(
async_handle_discovery_info(device, disc_info, value_updates_disc_info)
for disc_info in async_discover_node_values(
node, device, discovered_value_ids
)
)
)
# add listeners to handle new values that get added later
for event in ("value added", "value updated", "metadata updated"):
entry.async_on_unload(
node.on(
event,
lambda event: hass.async_create_task(
async_on_value_added(value_updates_disc_info, event["value"])
),
)
)
# add listener for stateless node value notification events
entry.async_on_unload(
node.on(
"value notification",
lambda event: async_on_value_notification(event["value_notification"]),
)
)
# add listener for stateless node notification events
entry.async_on_unload(node.on("notification", async_on_notification))
async def async_on_node_added(node: ZwaveNode) -> None:
"""Handle node added event."""
# No need for a ping button or node status sensor for controller nodes
if not node.is_controller_node:
# Create a node status sensor for each device
await async_setup_platform(Platform.SENSOR)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_node_status_sensor", node
)
# Create a ping button for each device
await async_setup_platform(Platform.BUTTON)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_ping_button_entity", node
)
# Create a firmware update entity for each device
await async_setup_platform(Platform.UPDATE)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_firmware_update_entity", node
)
# we only want to run discovery when the node has reached ready state,
# otherwise we'll have all kinds of missing info issues.
if node.ready:
await async_on_node_ready(node)
return
# if node is not yet ready, register one-time callback for ready state
LOGGER.debug("Node added: %s - waiting for it to become ready", node.node_id)
node.once(
"ready",
lambda event: hass.async_create_task(async_on_node_ready(event["node"])),
)
# we do submit the node to device registry so user has
# some visual feedback that something is (in the process of) being added
register_node_in_dev_reg(hass, entry, dev_reg, driver, node, remove_device)
async def async_on_value_added(
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
) -> None:
"""Fire value updated event."""
# If node isn't ready or a device for this node doesn't already exist, we can
# let the node ready event handler perform discovery. If a value has already
# been processed, we don't need to do it again
device_id = get_device_id(driver, value.node)
device_id = get_device_id(
self.controller_events.driver_events.driver, value.node
)
if (
not value.node.ready
or not (device := dev_reg.async_get_device({device_id}))
or value.value_id in discovered_value_ids[device.id]
or not (device := self.dev_reg.async_get_device({device_id}))
or value.value_id in self.controller_events.discovered_value_ids[device.id]
):
return
LOGGER.debug("Processing node %s added value %s", value.node, value)
await asyncio.gather(
*(
async_handle_discovery_info(device, disc_info, value_updates_disc_info)
self.async_handle_discovery_info(
device, disc_info, value_updates_disc_info
)
for disc_info in async_discover_single_value(
value, device, discovered_value_ids
value, device, self.controller_events.discovered_value_ids
)
)
)
@callback
def async_on_node_removed(event: dict) -> None:
"""Handle node removed event."""
node: ZwaveNode = event["node"]
replaced: bool = event.get("replaced", False)
# grab device in device registry attached to this node
dev_id = get_device_id(driver, node)
device = dev_reg.async_get_device({dev_id})
# We assert because we know the device exists
assert device
if replaced:
discovered_value_ids.pop(device.id, None)
async_dispatcher_send(
hass,
f"{DOMAIN}_{get_valueless_base_unique_id(driver, node)}_remove_entity",
)
else:
remove_device(device)
@callback
def async_on_value_notification(notification: ValueNotification) -> None:
def async_on_value_notification(self, notification: ValueNotification) -> None:
"""Relay stateless value notification events from Z-Wave nodes to hass."""
device = dev_reg.async_get_device({get_device_id(driver, notification.node)})
driver = self.controller_events.driver_events.driver
device = self.dev_reg.async_get_device(
{get_device_id(driver, notification.node)}
)
# We assert because we know the device exists
assert device
raw_value = value = notification.value
if notification.metadata.states:
value = notification.metadata.states.get(str(value), value)
hass.bus.async_fire(
self.hass.bus.async_fire(
ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
{
ATTR_DOMAIN: DOMAIN,
@@ -459,15 +586,19 @@ async def setup_driver( # noqa: C901
)
@callback
def async_on_notification(event: dict[str, Any]) -> None:
def async_on_notification(self, event: dict[str, Any]) -> None:
"""Relay stateless notification events from Z-Wave nodes to hass."""
if "notification" not in event:
LOGGER.info("Unknown notification: %s", event)
return
driver = self.controller_events.driver_events.driver
notification: EntryControlNotification | NotificationNotification | PowerLevelNotification | MultilevelSwitchNotification = event[
"notification"
]
device = dev_reg.async_get_device({get_device_id(driver, notification.node)})
device = self.dev_reg.async_get_device(
{get_device_id(driver, notification.node)}
)
# We assert because we know the device exists
assert device
event_data = {
@@ -521,31 +652,35 @@ async def setup_driver( # noqa: C901
else:
raise TypeError(f"Unhandled notification type: {notification}")
hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data)
self.hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data)
@callback
def async_on_value_updated_fire_event(
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
) -> None:
"""Fire value updated event."""
# Get the discovery info for the value that was updated. If there is
# no discovery info for this value, we don't need to fire an event
if value.value_id not in value_updates_disc_info:
return
driver = self.controller_events.driver_events.driver
disc_info = value_updates_disc_info[value.value_id]
device = dev_reg.async_get_device({get_device_id(driver, value.node)})
device = self.dev_reg.async_get_device({get_device_id(driver, value.node)})
# We assert because we know the device exists
assert device
unique_id = get_unique_id(driver, disc_info.primary_value.value_id)
entity_id = ent_reg.async_get_entity_id(disc_info.platform, DOMAIN, unique_id)
entity_id = self.ent_reg.async_get_entity_id(
disc_info.platform, DOMAIN, unique_id
)
raw_value = value_ = value.value
if value.metadata.states:
value_ = value.metadata.states.get(str(value), value_)
hass.bus.async_fire(
self.hass.bus.async_fire(
ZWAVE_JS_VALUE_UPDATED_EVENT,
{
ATTR_NODE_ID: value.node.node_id,
@@ -564,43 +699,6 @@ async def setup_driver( # noqa: C901
},
)
# If opt in preference hasn't been specified yet, we do nothing, otherwise
# we apply the preference
if opted_in := entry.data.get(CONF_DATA_COLLECTION_OPTED_IN):
await async_enable_statistics(driver)
elif opted_in is False:
await driver.async_disable_statistics()
# Check for nodes that no longer exist and remove them
stored_devices = device_registry.async_entries_for_config_entry(
dev_reg, entry.entry_id
)
known_devices = [
dev_reg.async_get_device({get_device_id(driver, node)})
for node in driver.controller.nodes.values()
]
# Devices that are in the device registry that are not known by the controller can be removed
for device in stored_devices:
if device not in known_devices:
dev_reg.async_remove_device(device.id)
# run discovery on all ready nodes
await asyncio.gather(
*(async_on_node_added(node) for node in driver.controller.nodes.values())
)
# listen for new nodes being added to the mesh
entry.async_on_unload(
driver.controller.on(
"node added",
lambda event: hass.async_create_task(async_on_node_added(event["node"])),
)
)
# listen for nodes being removed from the mesh
# NOTE: This will not remove nodes that were removed when HA was not running
entry.async_on_unload(driver.controller.on("node removed", async_on_node_removed))
async def client_listen(
hass: HomeAssistant,
@@ -633,14 +731,15 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None:
data = hass.data[DOMAIN][entry.entry_id]
client: ZwaveClient = data[DATA_CLIENT]
listen_task: asyncio.Task = data[DATA_CLIENT_LISTEN_TASK]
platform_task: asyncio.Task = data[DATA_START_PLATFORM_TASK]
start_client_task: asyncio.Task = data[DATA_START_CLIENT_TASK]
driver_events: DriverEvents = data[DATA_DRIVER_EVENTS]
listen_task.cancel()
platform_task.cancel()
platform_setup_tasks = data.get(DATA_PLATFORM_SETUP, {}).values()
start_client_task.cancel()
platform_setup_tasks = driver_events.platform_setup_tasks.values()
for task in platform_setup_tasks:
task.cancel()
await asyncio.gather(listen_task, platform_task, *platform_setup_tasks)
await asyncio.gather(listen_task, start_client_task, *platform_setup_tasks)
if client.connected:
await client.disconnect()
@@ -650,9 +749,10 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
info = hass.data[DOMAIN][entry.entry_id]
driver_events: DriverEvents = info[DATA_DRIVER_EVENTS]
tasks = []
for platform, task in info[DATA_PLATFORM_SETUP].items():
tasks: list[asyncio.Task | Coroutine] = []
for platform, task in driver_events.platform_setup_tasks.items():
if task.done():
tasks.append(
hass.config_entries.async_forward_entry_unload(entry, platform)
+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 -2
View File
@@ -21,7 +21,6 @@ CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in"
DOMAIN = "zwave_js"
DATA_CLIENT = "client"
DATA_PLATFORM_SETUP = "platform_setup"
EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry"
@@ -123,4 +122,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:
+75 -59
View File
@@ -1,14 +1,15 @@
"""Representation of Z-Wave updates."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import timedelta
from datetime import datetime, timedelta
from typing import Any
from awesomeversion import AwesomeVersion
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import NodeStatus
from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.firmware import FirmwareUpdateInfo
from zwave_js_server.model.node import Node as ZwaveNode
@@ -19,14 +20,15 @@ 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 homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
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)
async def async_setup_entry(
@@ -37,12 +39,14 @@ async def async_setup_entry(
"""Set up Z-Wave button from config entry."""
client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
semaphore = asyncio.Semaphore(3)
@callback
def async_add_firmware_update_entity(node: ZwaveNode) -> None:
"""Add firmware update entity."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
async_add_entities([ZWaveNodeFirmwareUpdate(driver, node)], True)
async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, semaphore)])
config_entry.async_on_unload(
async_dispatcher_connect(
@@ -62,69 +66,79 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
UpdateEntityFeature.INSTALL | UpdateEntityFeature.RELEASE_NOTES
)
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, driver: Driver, node: ZwaveNode) -> None:
def __init__(
self, driver: Driver, node: ZwaveNode, semaphore: asyncio.Semaphore
) -> None:
"""Initialize a Z-Wave device firmware update entity."""
self.driver = driver
self.node = node
self.semaphore = semaphore
self._latest_version_firmware: FirmwareUpdateInfo | None = None
self._status_unsub: Callable[[], None] | None = None
self._poll_unsub: Callable[[], None] | None = None
# Entity class attributes
self._attr_name = "Firmware"
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_installed_version = self._attr_latest_version = node.firmware_version
def _update_on_wake_up(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
if available_firmware_updates := (
await self.driver.controller.async_get_available_firmware_updates(
self.node, API_KEY_FIRMWARE_UPDATE_SERVICE
)
):
self._latest_version_firmware = max(
available_firmware_updates,
key=lambda x: AwesomeVersion(x.version),
)
self._async_process_available_updates(write_state)
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
@callback
def _async_process_available_updates(self, write_state: bool = True) -> None:
"""
Process available firmware updates.
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())
Sets latest version attribute and FirmwareUpdateInfo instance.
"""
# If we have an available firmware update that is a higher version than what's
# on the node, we should advertise it, otherwise we are on the latest version
if (firmware := self._latest_version_firmware) and AwesomeVersion(
firmware.version
) > AwesomeVersion(self.node.firmware_version):
self._attr_latest_version = firmware.version
async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None:
"""Update the entity."""
self._poll_unsub = None
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
try:
async with self.semaphore:
available_firmware_updates = (
await self.driver.controller.async_get_available_firmware_updates(
self.node, API_KEY_FIRMWARE_UPDATE_SERVICE
)
)
except FailedZWaveCommand as err:
LOGGER.debug(
"Failed to get firmware updates for node %s: %s",
self.node.node_id,
err,
)
else:
self._attr_latest_version = self._attr_installed_version
if write_state:
self.async_write_ha_state()
if available_firmware_updates:
self._latest_version_firmware = latest_firmware = max(
available_firmware_updates,
key=lambda x: AwesomeVersion(x.version),
)
# If we have an available firmware update that is a higher version than
# what's on the node, we should advertise it, otherwise there is
# nothing to do.
new_version = latest_firmware.version
current_version = self.node.firmware_version
if AwesomeVersion(new_version) > AwesomeVersion(current_version):
self._attr_latest_version = new_version
self.async_write_ha_state()
finally:
self._poll_unsub = async_call_later(
self.hass, timedelta(days=1), self._async_update
)
async def async_release_notes(self) -> str | None:
"""Get release notes."""
@@ -138,8 +152,6 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
"""Install an update."""
firmware = self._latest_version_firmware
assert firmware
self._attr_in_progress = True
self.async_write_ha_state()
try:
for file in firmware.files:
await self.driver.controller.async_begin_ota_firmware_update(
@@ -148,11 +160,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
except BaseZwaveJSServerError as err:
raise HomeAssistantError(err) from err
else:
self._attr_installed_version = firmware.version
self._attr_installed_version = self._attr_latest_version = firmware.version
self._latest_version_firmware = None
self._async_process_available_updates()
finally:
self._attr_in_progress = False
self.async_write_ha_state()
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
@@ -179,8 +189,14 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
)
)
self.async_on_remove(async_at_start(self.hass, self._async_update))
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed."""
if self._status_unsub:
self._status_unsub()
self._status_unsub = None
if self._poll_unsub:
self._poll_unsub()
self._poll_unsub = None
+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 = "0b6"
__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)
+22
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": [
@@ -252,6 +269,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
"local_name": "TP35*",
"connectable": False
},
{
"domain": "thermopro",
"local_name": "TP39*",
"connectable": False
},
{
"domain": "xiaomi_ble",
"connectable": False,
+1
View File
@@ -48,6 +48,7 @@ FLOWS = {
"balboa",
"blebox",
"blink",
"bluemaestro",
"bluetooth",
"bmw_connected_drive",
"bond",
+5 -4
View File
@@ -11,15 +11,15 @@ attrs==21.2.0
awesomeversion==22.8.0
bcrypt==3.1.7
bleak==0.16.0
bluetooth-adapters==0.3.3
bluetooth-auto-recovery==0.3.0
bluetooth-adapters==0.3.4
bluetooth-auto-recovery==0.3.1
certifi>=2021.5.30
ciso8601==2.2.0
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==20220906.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
@@ -39,7 +40,7 @@ typing-extensions>=3.10.0.2,<5.0
voluptuous-serialize==2.5.0
voluptuous==0.13.1
yarl==1.7.2
zeroconf==0.39.0
zeroconf==0.39.1
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
-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.0b6"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
+20 -17
View File
@@ -422,15 +422,18 @@ 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
bluetooth-auto-recovery==0.3.1
# homeassistant.components.bond
bond-async==0.1.22
@@ -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
@@ -627,7 +630,7 @@ envoy_reader==0.20.1
ephem==4.1.2
# homeassistant.components.epson
epson-projector==0.4.6
epson-projector==0.5.0
# homeassistant.components.epsonworkforce
epsonprinter==0.0.9
@@ -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==20220906.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.2
# 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
@@ -2363,7 +2366,7 @@ tesla-wall-connector==1.0.2
thermobeacon-ble==0.3.1
# homeassistant.components.thermopro
thermopro-ble==0.4.0
thermopro-ble==0.4.3
# homeassistant.components.thermoworks_smoke
thermoworks_smoke==0.1.8
@@ -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
@@ -2563,7 +2566,7 @@ youtube_dl==2021.12.17
zengge==0.2
# homeassistant.components.zeroconf
zeroconf==0.39.0
zeroconf==0.39.1
# homeassistant.components.zha
zha-quirks==0.0.79
+1
View File
@@ -7,6 +7,7 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==2.12.5
codecov==2.1.12
coverage==6.4.4
freezegun==1.2.1
+18 -15
View File
@@ -337,11 +337,14 @@ blebox_uniapi==2.0.2
# homeassistant.components.blink
blinkpy==0.19.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.3.3
# homeassistant.components.bluemaestro
bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.0
bluetooth-adapters==0.3.4
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.1
# homeassistant.components.bond
bond-async==0.1.22
@@ -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
@@ -474,7 +477,7 @@ envoy_reader==0.20.1
ephem==4.1.2
# homeassistant.components.epson
epson-projector==0.4.6
epson-projector==0.5.0
# homeassistant.components.faa_delays
faadelays==0.0.7
@@ -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==20220906.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.2
# 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
@@ -1612,7 +1615,7 @@ tesla-wall-connector==1.0.2
thermobeacon-ble==0.3.1
# homeassistant.components.thermopro
thermopro-ble==0.4.0
thermopro-ble==0.4.3
# homeassistant.components.todoist
todoist-python==8.0.0
@@ -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
@@ -1758,7 +1761,7 @@ yolink-api==0.0.9
youless-api==0.16
# homeassistant.components.zeroconf
zeroconf==0.39.0
zeroconf==0.39.1
# homeassistant.components.zha
zha-quirks==0.0.79
+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"
+44 -1
View File
@@ -9,7 +9,7 @@ import pytest
from homeassistant import config as hass_config
from homeassistant.components.history_stats import DOMAIN
from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN
from homeassistant.const import ATTR_DEVICE_CLASS, SERVICE_RELOAD, STATE_UNKNOWN
import homeassistant.core as ha
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component, setup_component
@@ -1496,3 +1496,46 @@ async def test_end_time_with_microseconds_zeroed(time_zone, hass, recorder_mock)
async_fire_time_changed(hass, rolled_to_next_day_plus_18)
await hass.async_block_till_done()
assert hass.states.get("sensor.heatpump_compressor_today").state == "16.0"
async def test_device_classes(hass, recorder_mock):
"""Test the device classes."""
await async_setup_component(
hass,
"sensor",
{
"sensor": [
{
"platform": "history_stats",
"entity_id": "binary_sensor.test_id",
"name": "time",
"state": "on",
"start": "{{ as_timestamp(now()) - 3600 }}",
"end": "{{ as_timestamp(now()) + 3600 }}",
"type": "time",
},
{
"platform": "history_stats",
"entity_id": "binary_sensor.test_id",
"name": "count",
"state": "on",
"start": "{{ as_timestamp(now()) - 3600 }}",
"end": "{{ as_timestamp(now()) + 3600 }}",
"type": "count",
},
{
"platform": "history_stats",
"entity_id": "binary_sensor.test_id",
"name": "ratio",
"state": "on",
"start": "{{ as_timestamp(now()) - 3600 }}",
"end": "{{ as_timestamp(now()) + 3600 }}",
"type": "ratio",
},
]
},
)
await hass.async_block_till_done()
assert hass.states.get("sensor.time").attributes[ATTR_DEVICE_CLASS] == "duration"
assert ATTR_DEVICE_CLASS not in hass.states.get("sensor.ratio").attributes
assert ATTR_DEVICE_CLASS not in hass.states.get("sensor.count").attributes

Some files were not shown because too many files have changed in this diff Show More