Compare commits

..

81 Commits

Author SHA1 Message Date
Paulus Schoutsen
0a7f3f6ced 2022.9.1 (#78081) 2022-09-08 21:58:18 -04:00
rlippmann
fee9a303ff Fix issue #77920 - ecobee remote sensors not updating (#78035) 2022-09-08 21:02:32 -04:00
Paulus Schoutsen
a4f398a750 Bumped version to 2022.9.1 2022-09-08 16:50:47 -04:00
Jan Bouwhuis
c873eae79c Allow OpenWeatherMap config flow to test using old API to pass (#78074)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-09-08 16:50:13 -04:00
Nathan Spencer
d559b6482a Bump pylitterbot to 2022.9.1 (#78071) 2022-09-08 16:50:12 -04:00
Aaron Bach
760853f615 Fix bug with 1st gen RainMachine controllers and unknown API calls (#78070)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2022-09-08 16:50:12 -04:00
J. Nick Koston
cfe8ebdad4 Bump bluetooth-auto-recovery to 0.3.2 (#78063) 2022-09-08 16:50:11 -04:00
J. Nick Koston
2ddd1b516c Bump bluetooth-adapters to 0.3.5 (#78052) 2022-09-08 16:50:10 -04:00
Martin Hjelmare
3b025b211e Fix zwave_js device re-interview (#78046)
* Handle stale node and entity info on re-interview

* Add test

* Unsubscribe on config entry unload
2022-09-08 16:50:10 -04:00
Maikel Punie
4009a32fb5 Bump velbus-aio to 2022.9.1 (#78039)
Bump velbusaio to 2022.9.1
2022-09-08 16:50:09 -04:00
Joakim Sørensen
6f3b49601e Extract lametric device from coordinator in notify (#78027) 2022-09-08 16:50:08 -04:00
Martin Hjelmare
31858ad779 Fix zwave_js default emulate hardware in options flow (#78024) 2022-09-08 16:50:08 -04:00
Raman Gupta
ab9d9d599e Add value ID to zwave_js device diagnostics (#78015) 2022-09-08 16:50:07 -04:00
Yevhenii Vaskivskyi
ce6d337bd5 Fix len method typo for Osram light (#78008)
* Fix `len` method typo for Osram light

* Fix typo in line 395
2022-09-08 16:50:06 -04:00
Raman Gupta
3fd887b1f2 Show progress for zwave_js.update entity (#77905)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-09-08 16:50:05 -04:00
Raman Gupta
996a3477b0 Increase rate limit for zwave_js updates
Al provided a new key which bumps the rate limit from 10k per hour to 100k per hour
2022-09-08 14:03:04 -04:00
Paulus Schoutsen
910f27f3a2 2022.9.0 (#77968) 2022-09-07 12:49:59 -04:00
Franck Nijhof
4ab5cdcb79 Bumped version to 2022.9.0 2022-09-07 17:46:53 +02:00
Bram Kragten
e69fde6875 Update frontend to 20220907.0 (#77963) 2022-09-07 17:45:47 +02:00
J. Nick Koston
10f7e2ff8a Handle stale switchbot advertisement data while connected (#77956) 2022-09-07 17:45:42 +02:00
J. Nick Koston
3acc3af38c Bump PySwitchbot to 0.18.25 (#77935) 2022-09-07 17:45:36 +02:00
J. Nick Koston
a3edbfc601 Small tweaks to improve performance of bluetooth matching (#77934)
* Small tweaks to improve performance of bluetooth matching

* Small tweaks to improve performance of bluetooth matching

* cleanup
2022-09-07 17:45:31 +02:00
J. Nick Koston
941a5e3820 Bump led-ble to 0.7.1 (#77931) 2022-09-07 17:45:26 +02:00
J. Nick Koston
2eeab820b7 Bump aiohomekit to 1.5.2 (#77927) 2022-09-07 17:45:21 +02:00
Franck Nijhof
8d0ebdd1f9 Revert "Add ability to ignore devices for UniFi Protect" (#77916) 2022-09-07 17:45:16 +02:00
Raman Gupta
9901b31316 Bump zwave-js-server-python to 0.41.1 (#77915)
* Bump zwave-js-server-python to 0.41.1

* Fix fixture
2022-09-07 17:45:11 +02:00
Chris McCurdy
a4f528e908 Add additional method of retrieving UUID for LG soundbar configuration (#77856) 2022-09-07 17:45:05 +02:00
puddly
9aa87761cf Fix ZHA lighting initial hue/saturation attribute read (#77727)
* Handle the case of `current_hue` being `None`

* WIP unit tests
2022-09-07 17:45:00 +02:00
Matthew Simpson
d1b637ea7a Bump btsmarthub_devicelist to 0.2.2 (#77609) 2022-09-07 17:44:54 +02:00
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
198 changed files with 4901 additions and 3340 deletions

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

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

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"

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""Constants for the BlueMaestro integration."""
DOMAIN = "bluemaestro"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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.5",
"bluetooth-auto-recovery==0.3.2"
],
"codeowners": ["@bdraco"],
"config_flow": true,

View File

@@ -180,12 +180,20 @@ class BluetoothMatcherIndexBase(Generic[_T]):
We put them in the bucket that they are most likely to match.
"""
# Local name is the cheapest to match since its just a dict lookup
if LOCAL_NAME in matcher:
self.local_name.setdefault(
_local_name_to_index_key(matcher[LOCAL_NAME]), []
).append(matcher)
return
# Manufacturer data is 2nd cheapest since its all ints
if MANUFACTURER_ID in matcher:
self.manufacturer_id.setdefault(matcher[MANUFACTURER_ID], []).append(
matcher
)
return
if SERVICE_UUID in matcher:
self.service_uuid.setdefault(matcher[SERVICE_UUID], []).append(matcher)
return
@@ -196,12 +204,6 @@ class BluetoothMatcherIndexBase(Generic[_T]):
)
return
if MANUFACTURER_ID in matcher:
self.manufacturer_id.setdefault(matcher[MANUFACTURER_ID], []).append(
matcher
)
return
def remove(self, matcher: _T) -> None:
"""Remove a matcher from the index.
@@ -214,6 +216,10 @@ class BluetoothMatcherIndexBase(Generic[_T]):
)
return
if MANUFACTURER_ID in matcher:
self.manufacturer_id[matcher[MANUFACTURER_ID]].remove(matcher)
return
if SERVICE_UUID in matcher:
self.service_uuid[matcher[SERVICE_UUID]].remove(matcher)
return
@@ -222,10 +228,6 @@ class BluetoothMatcherIndexBase(Generic[_T]):
self.service_data_uuid[matcher[SERVICE_DATA_UUID]].remove(matcher)
return
if MANUFACTURER_ID in matcher:
self.manufacturer_id[matcher[MANUFACTURER_ID]].remove(matcher)
return
def build(self) -> None:
"""Rebuild the index sets."""
self.service_uuid_set = set(self.service_uuid)
@@ -235,33 +237,36 @@ class BluetoothMatcherIndexBase(Generic[_T]):
def match(self, service_info: BluetoothServiceInfoBleak) -> list[_T]:
"""Check for a match."""
matches = []
if len(service_info.name) >= LOCAL_NAME_MIN_MATCH_LENGTH:
if service_info.name and len(service_info.name) >= LOCAL_NAME_MIN_MATCH_LENGTH:
for matcher in self.local_name.get(
service_info.name[:LOCAL_NAME_MIN_MATCH_LENGTH], []
):
if ble_device_matches(matcher, service_info):
matches.append(matcher)
for service_data_uuid in self.service_data_uuid_set.intersection(
service_info.service_data
):
for matcher in self.service_data_uuid[service_data_uuid]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
if self.service_data_uuid_set and service_info.service_data:
for service_data_uuid in self.service_data_uuid_set.intersection(
service_info.service_data
):
for matcher in self.service_data_uuid[service_data_uuid]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
for manufacturer_id in self.manufacturer_id_set.intersection(
service_info.manufacturer_data
):
for matcher in self.manufacturer_id[manufacturer_id]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
if self.manufacturer_id_set and service_info.manufacturer_data:
for manufacturer_id in self.manufacturer_id_set.intersection(
service_info.manufacturer_data
):
for matcher in self.manufacturer_id[manufacturer_id]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
for service_uuid in self.service_uuid_set.intersection(
service_info.service_uuids
):
for matcher in self.service_uuid[service_uuid]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
if self.service_uuid_set and service_info.service_uuids:
for service_uuid in self.service_uuid_set.intersection(
service_info.service_uuids
):
for matcher in self.service_uuid[service_uuid]:
if ble_device_matches(matcher, service_info):
matches.append(matcher)
return matches
@@ -347,8 +352,6 @@ def ble_device_matches(
service_info: BluetoothServiceInfoBleak,
) -> bool:
"""Check if a ble device and advertisement_data matches the matcher."""
device = service_info.device
# Don't check address here since all callers already
# check the address and we don't want to double check
# since it would result in an unreachable reject case.
@@ -379,7 +382,8 @@ def ble_device_matches(
return False
if (local_name := matcher.get(LOCAL_NAME)) and (
(device_name := advertisement_data.local_name or device.name) is None
(device_name := advertisement_data.local_name or service_info.device.name)
is None
or not _memorized_fnmatch(
device_name,
local_name,

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
"domain": "bt_smarthub",
"name": "BT Smart Hub",
"documentation": "https://www.home-assistant.io/integrations/bt_smarthub",
"requirements": ["btsmarthub_devicelist==0.2.0"],
"requirements": ["btsmarthub_devicelist==0.2.2"],
"codeowners": ["@jxwolstenholme"],
"iot_class": "local_polling",
"loggers": ["btsmarthub_devicelist"]

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

View File

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

View File

@@ -1,3 +1,3 @@
"""Constants for the BThome Bluetooth integration."""
"""Constants for the BTHome Bluetooth integration."""
DOMAIN = "bthome"

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

View File

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

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:

View File

@@ -11,8 +11,9 @@
"dhcp",
"energy",
"frontend",
"homeassistant_alerts",
"hardware",
"history",
"homeassistant_alerts",
"input_boolean",
"input_button",
"input_datetime",

View File

@@ -29,7 +29,7 @@ from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
class EcobeeSensorEntityDescriptionMixin:
"""Represent the required ecobee entity description attributes."""
runtime_key: str
runtime_key: str | None
@dataclass
@@ -46,7 +46,7 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = (
native_unit_of_measurement=TEMP_FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
runtime_key="actualTemperature",
runtime_key=None,
),
EcobeeSensorEntityDescription(
key="humidity",
@@ -54,7 +54,7 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
runtime_key="actualHumidity",
runtime_key=None,
),
EcobeeSensorEntityDescription(
key="co2PPM",
@@ -194,6 +194,11 @@ class EcobeeSensor(SensorEntity):
for item in sensor["capability"]:
if item["type"] != self.entity_description.key:
continue
thermostat = self.data.ecobee.get_thermostat(self.index)
self._state = thermostat["runtime"][self.entity_description.runtime_key]
if self.entity_description.runtime_key is None:
self._state = item["value"]
else:
thermostat = self.data.ecobee.get_thermostat(self.index)
self._state = thermostat["runtime"][
self.entity_description.runtime_key
]
break

View File

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

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

View File

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

View File

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

View File

@@ -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==20220907.0"],
"dependencies": [
"api",
"auth",

View File

@@ -51,6 +51,7 @@ SETTINGS_TO_REDACT = {
"sebExamKey",
"sebConfigKey",
"kioskPinEnc",
"remoteAdminPasswordEnc",
}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==1.5.1"],
"requirements": ["aiohomekit==1.5.2"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
"dependencies": ["bluetooth", "zeroconf"],

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

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

View File

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

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:

View File

@@ -21,6 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
async def async_get_service(
@@ -31,8 +32,10 @@ async def async_get_service(
"""Get the LaMetric notification service."""
if discovery_info is None:
return None
lametric: LaMetricDevice = hass.data[DOMAIN][discovery_info["entry_id"]]
return LaMetricNotificationService(lametric)
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][
discovery_info["entry_id"]
]
return LaMetricNotificationService(coordinator.lametric)
class LaMetricNotificationService(BaseNotificationService):

View File

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

View File

@@ -1,5 +1,5 @@
"""Config flow to configure the LG Soundbar integration."""
from queue import Queue
from queue import Full, Queue
import socket
import temescal
@@ -20,18 +20,29 @@ def test_connect(host, port):
uuid_q = Queue(maxsize=1)
name_q = Queue(maxsize=1)
def queue_add(attr_q, data):
try:
attr_q.put_nowait(data)
except Full:
pass
def msg_callback(response):
if response["msg"] == "MAC_INFO_DEV" and "s_uuid" in response["data"]:
uuid_q.put_nowait(response["data"]["s_uuid"])
if (
response["msg"] in ["MAC_INFO_DEV", "PRODUCT_INFO"]
and "s_uuid" in response["data"]
):
queue_add(uuid_q, response["data"]["s_uuid"])
if (
response["msg"] == "SPK_LIST_VIEW_INFO"
and "s_user_name" in response["data"]
):
name_q.put_nowait(response["data"]["s_user_name"])
queue_add(name_q, response["data"]["s_user_name"])
try:
connection = temescal.temescal(host, port=port, callback=msg_callback)
connection.get_mac_info()
if uuid_q.empty():
connection.get_product_info()
connection.get_info()
details = {"name": name_q.get(timeout=10), "uuid": uuid_q.get(timeout=10)}
return details

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)

View File

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

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

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:

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)

View File

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

View File

@@ -3,7 +3,7 @@
"name": "Litter-Robot",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
"requirements": ["pylitterbot==2022.8.2"],
"requirements": ["pylitterbot==2022.9.1"],
"codeowners": ["@natekspencer", "@tkdrob"],
"dhcp": [{ "hostname": "litter-robot4" }],
"iot_class": "cloud_polling",

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

View File

@@ -130,4 +130,4 @@ class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow):
async def _is_owm_api_online(hass, api_key, lat, lon):
owm = OWM(api_key).weather_manager()
return await hass.async_add_executor_job(owm.one_call, lat, lon)
return await hass.async_add_executor_job(owm.weather_at_coords, lat, lon)

View File

@@ -373,7 +373,7 @@ class Luminary(LightEntity):
self._max_mireds = color_util.color_temperature_kelvin_to_mired(
self._luminary.min_temp() or DEFAULT_KELVIN
)
if len(self._attr_supported_color_modes == 1):
if len(self._attr_supported_color_modes) == 1:
# The light supports only a single color mode
self._attr_color_mode = list(self._attr_supported_color_modes)[0]
@@ -392,7 +392,7 @@ class Luminary(LightEntity):
if ColorMode.HS in self._attr_supported_color_modes:
self._rgb_color = self._luminary.rgb()
if len(self._attr_supported_color_modes > 1):
if len(self._attr_supported_color_modes) > 1:
# The light supports hs + color temp, determine which one it is
if self._rgb_color == (0, 0, 0):
self._attr_color_mode = ColorMode.COLOR_TEMP

View File

@@ -9,7 +9,7 @@ from typing import Any
from regenmaschine import Client
from regenmaschine.controller import Controller
from regenmaschine.errors import RainMachineError
from regenmaschine.errors import RainMachineError, UnknownAPICallError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -190,7 +190,9 @@ async def async_update_programs_and_zones(
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry( # noqa: C901
hass: HomeAssistant, entry: ConfigEntry
) -> bool:
"""Set up RainMachine as config entry."""
websession = aiohttp_client.async_get_clientsession(hass)
client = Client(session=websession)
@@ -244,6 +246,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data = await controller.restrictions.universal()
else:
data = await controller.zones.all(details=True, include_inactive=True)
except UnknownAPICallError:
LOGGER.info(
"Skipping unsupported API call for controller %s: %s",
controller.name,
api_category,
)
except RainMachineError as err:
raise UpdateFailed(err) from err

View File

@@ -175,7 +175,9 @@ class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity):
def update_from_latest_data(self) -> None:
"""Update the state."""
if self.entity_description.key == TYPE_FLOW_SENSOR:
self._attr_is_on = self.coordinator.data["system"].get("useFlowSensor")
self._attr_is_on = self.coordinator.data.get("system", {}).get(
"useFlowSensor"
)
class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity):

View File

@@ -3,7 +3,7 @@
"name": "RainMachine",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainmachine",
"requirements": ["regenmaschine==2022.08.0"],
"requirements": ["regenmaschine==2022.09.0"],
"codeowners": ["@bachya"],
"iot_class": "local_polling",
"homekit": {

View File

@@ -273,12 +273,14 @@ class ProvisionSettingsSensor(RainMachineEntity, SensorEntity):
def update_from_latest_data(self) -> None:
"""Update the state."""
if self.entity_description.key == TYPE_FLOW_SENSOR_CLICK_M3:
self._attr_native_value = self.coordinator.data["system"].get(
self._attr_native_value = self.coordinator.data.get("system", {}).get(
"flowSensorClicksPerCubicMeter"
)
elif self.entity_description.key == TYPE_FLOW_SENSOR_CONSUMED_LITERS:
clicks = self.coordinator.data["system"].get("flowSensorWateringClicks")
clicks_per_m3 = self.coordinator.data["system"].get(
clicks = self.coordinator.data.get("system", {}).get(
"flowSensorWateringClicks"
)
clicks_per_m3 = self.coordinator.data.get("system", {}).get(
"flowSensorClicksPerCubicMeter"
)
@@ -287,11 +289,11 @@ class ProvisionSettingsSensor(RainMachineEntity, SensorEntity):
else:
self._attr_native_value = None
elif self.entity_description.key == TYPE_FLOW_SENSOR_START_INDEX:
self._attr_native_value = self.coordinator.data["system"].get(
self._attr_native_value = self.coordinator.data.get("system", {}).get(
"flowSensorStartIndex"
)
elif self.entity_description.key == TYPE_FLOW_SENSOR_WATERING_CLICKS:
self._attr_native_value = self.coordinator.data["system"].get(
self._attr_native_value = self.coordinator.data.get("system", {}).get(
"flowSensorWateringClicks"
)

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,

View File

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

View File

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

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(

View File

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

View File

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

View File

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

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

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)

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

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

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

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:

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

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

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]

View File

@@ -31,6 +31,7 @@ from .coordinator import SwitchbotDataUpdateCoordinator
PLATFORMS_BY_TYPE = {
SupportedModels.BULB.value: [Platform.SENSOR, Platform.LIGHT],
SupportedModels.LIGHT_STRIP.value: [Platform.SENSOR, Platform.LIGHT],
SupportedModels.CEILING_LIGHT.value: [Platform.SENSOR, Platform.LIGHT],
SupportedModels.BOT.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.PLUG.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.CURTAIN.value: [
@@ -43,6 +44,7 @@ PLATFORMS_BY_TYPE = {
SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
SupportedModels.CURTAIN.value: switchbot.SwitchbotCurtain,
SupportedModels.BOT.value: switchbot.Switchbot,
SupportedModels.PLUG.value: switchbot.SwitchbotPlugMini,

View File

@@ -16,6 +16,7 @@ class SupportedModels(StrEnum):
BOT = "bot"
BULB = "bulb"
CEILING_LIGHT = "ceiling_light"
CURTAIN = "curtain"
HYGROMETER = "hygrometer"
LIGHT_STRIP = "light_strip"
@@ -30,6 +31,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.PLUG_MINI: SupportedModels.PLUG,
SwitchbotModel.COLOR_BULB: SupportedModels.BULB,
SwitchbotModel.LIGHT_STRIP: SupportedModels.LIGHT_STRIP,
SwitchbotModel.CEILING_LIGHT: SupportedModels.CEILING_LIGHT,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from typing import Any
import switchbot
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
@@ -36,6 +38,7 @@ async def async_setup_entry(
class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""Representation of a Switchbot."""
_device: switchbot.SwitchbotCurtain
_attr_device_class = CoverDeviceClass.CURTAIN
_attr_supported_features = (
CoverEntityFeature.OPEN

View File

@@ -2,7 +2,7 @@
"domain": "switchbot",
"name": "SwitchBot",
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"requirements": ["PySwitchbot==0.18.22"],
"requirements": ["PySwitchbot==0.18.27"],
"config_flow": true,
"dependencies": ["bluetooth"],
"codeowners": [

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from typing import Any
import switchbot
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
@@ -34,6 +36,7 @@ class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity):
"""Representation of a Switchbot switch."""
_attr_device_class = SwitchDeviceClass.SWITCH
_device: switchbot.Switchbot
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the Switchbot."""
@@ -69,21 +72,19 @@ class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity):
@property
def assumed_state(self) -> bool:
"""Return true if unable to access real state of entity."""
if not self.data["data"]["switchMode"]:
return True
return False
return not self._device.switch_mode()
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
if not self.data["data"]["switchMode"]:
if not self._device.switch_mode():
return self._attr_is_on
return self.data["data"]["isOn"]
return self._device.is_on()
@property
def extra_state_attributes(self) -> dict:
"""Return the state attributes."""
return {
**super().extra_state_attributes,
"switch_mode": self.data["data"]["switchMode"],
"switch_mode": self._device.switch_mode(),
}

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
CONF_ALL_UPDATES,
CONF_IGNORED,
CONF_OVERRIDE_CHOST,
DEFAULT_SCAN_INTERVAL,
DEVICES_FOR_SUBSCRIBE,
@@ -36,11 +35,11 @@ from .const import (
OUTDATED_LOG_MESSAGE,
PLATFORMS,
)
from .data import ProtectData
from .data import ProtectData, async_ufp_instance_for_config_entry_ids
from .discovery import async_start_discovery
from .migrate import async_migrate_data
from .services import async_cleanup_services, async_setup_services
from .utils import async_unifi_mac, convert_mac_list
from .utils import _async_unifi_mac_from_hass, async_get_devices
from .views import ThumbnailProxyView, VideoProxyView
_LOGGER = logging.getLogger(__name__)
@@ -107,19 +106,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update options."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
changed = data.async_get_changed_options(entry)
if len(changed) == 1 and CONF_IGNORED in changed:
new_macs = convert_mac_list(entry.options.get(CONF_IGNORED, ""))
added_macs = new_macs - data.ignored_macs
removed_macs = data.ignored_macs - new_macs
# if only ignored macs are added, we can handle without reloading
if not removed_macs and added_macs:
data.async_add_new_ignored_macs(added_macs)
return
await hass.config_entries.async_reload(entry.entry_id)
@@ -139,15 +125,15 @@ async def async_remove_config_entry_device(
) -> bool:
"""Remove ufp config entry from a device."""
unifi_macs = {
async_unifi_mac(connection[1])
_async_unifi_mac_from_hass(connection[1])
for connection in device_entry.connections
if connection[0] == dr.CONNECTION_NETWORK_MAC
}
data: ProtectData = hass.data[DOMAIN][config_entry.entry_id]
if data.api.bootstrap.nvr.mac in unifi_macs:
api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id})
assert api is not None
if api.bootstrap.nvr.mac in unifi_macs:
return False
for device in data.get_by_types(DEVICES_THAT_ADOPT):
for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT):
if device.is_adopted_by_us and device.mac in unifi_macs:
data.async_ignore_mac(device.mac)
break
return False
return True

View File

@@ -35,7 +35,6 @@ from homeassistant.util.network import is_ip_address
from .const import (
CONF_ALL_UPDATES,
CONF_DISABLE_RTSP,
CONF_IGNORED,
CONF_MAX_MEDIA,
CONF_OVERRIDE_CHOST,
DEFAULT_MAX_MEDIA,
@@ -47,7 +46,7 @@ from .const import (
)
from .data import async_last_update_was_successful
from .discovery import async_start_discovery
from .utils import _async_resolve, async_short_mac, async_unifi_mac, convert_mac_list
from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass
_LOGGER = logging.getLogger(__name__)
@@ -121,7 +120,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle integration discovery."""
self._discovered_device = discovery_info
mac = async_unifi_mac(discovery_info["hw_addr"])
mac = _async_unifi_mac_from_hass(discovery_info["hw_addr"])
await self.async_set_unique_id(mac)
source_ip = discovery_info["source_ip"]
direct_connect_domain = discovery_info["direct_connect_domain"]
@@ -183,7 +182,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
placeholders = {
"name": discovery_info["hostname"]
or discovery_info["platform"]
or f"NVR {async_short_mac(discovery_info['hw_addr'])}",
or f"NVR {_async_short_mac(discovery_info['hw_addr'])}",
"ip_address": discovery_info["source_ip"],
}
self.context["title_placeholders"] = placeholders
@@ -225,7 +224,6 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_ALL_UPDATES: False,
CONF_OVERRIDE_CHOST: False,
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
CONF_IGNORED: "",
},
)
@@ -367,53 +365,33 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
values = user_input or self.config_entry.options
schema = vol.Schema(
{
vol.Optional(
CONF_DISABLE_RTSP,
description={
"suggested_value": values.get(CONF_DISABLE_RTSP, False)
},
): bool,
vol.Optional(
CONF_ALL_UPDATES,
description={
"suggested_value": values.get(CONF_ALL_UPDATES, False)
},
): bool,
vol.Optional(
CONF_OVERRIDE_CHOST,
description={
"suggested_value": values.get(CONF_OVERRIDE_CHOST, False)
},
): bool,
vol.Optional(
CONF_MAX_MEDIA,
description={
"suggested_value": values.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA)
},
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)),
vol.Optional(
CONF_IGNORED,
description={"suggested_value": values.get(CONF_IGNORED, "")},
): str,
}
)
errors: dict[str, str] = {}
if user_input is not None:
try:
convert_mac_list(user_input.get(CONF_IGNORED, ""), raise_exception=True)
except vol.Invalid:
errors[CONF_IGNORED] = "invalid_mac_list"
if not errors:
return self.async_create_entry(title="", data=user_input)
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=schema,
errors=errors,
data_schema=vol.Schema(
{
vol.Optional(
CONF_DISABLE_RTSP,
default=self.config_entry.options.get(CONF_DISABLE_RTSP, False),
): bool,
vol.Optional(
CONF_ALL_UPDATES,
default=self.config_entry.options.get(CONF_ALL_UPDATES, False),
): bool,
vol.Optional(
CONF_OVERRIDE_CHOST,
default=self.config_entry.options.get(
CONF_OVERRIDE_CHOST, False
),
): bool,
vol.Optional(
CONF_MAX_MEDIA,
default=self.config_entry.options.get(
CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA
),
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)),
}
),
)

View File

@@ -20,7 +20,6 @@ CONF_DISABLE_RTSP = "disable_rtsp"
CONF_ALL_UPDATES = "all_updates"
CONF_OVERRIDE_CHOST = "override_connection_host"
CONF_MAX_MEDIA = "max_media"
CONF_IGNORED = "ignored_devices"
CONFIG_OPTIONS = [
CONF_ALL_UPDATES,

View File

@@ -28,7 +28,6 @@ from homeassistant.helpers.event import async_track_time_interval
from .const import (
CONF_DISABLE_RTSP,
CONF_IGNORED,
CONF_MAX_MEDIA,
DEFAULT_MAX_MEDIA,
DEVICES_THAT_ADOPT,
@@ -37,11 +36,7 @@ from .const import (
DISPATCH_CHANNELS,
DOMAIN,
)
from .utils import (
async_dispatch_id as _ufpd,
async_get_devices_by_type,
convert_mac_list,
)
from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type
_LOGGER = logging.getLogger(__name__)
ProtectDeviceType = Union[ProtectAdoptableDeviceModel, NVR]
@@ -72,7 +67,6 @@ class ProtectData:
self._hass = hass
self._entry = entry
self._existing_options = dict(entry.options)
self._hass = hass
self._update_interval = update_interval
self._subscriptions: dict[str, list[Callable[[ProtectDeviceType], None]]] = {}
@@ -80,8 +74,6 @@ class ProtectData:
self._unsub_interval: CALLBACK_TYPE | None = None
self._unsub_websocket: CALLBACK_TYPE | None = None
self._auth_failures = 0
self._ignored_macs: set[str] | None = None
self._ignore_update_cancel: Callable[[], None] | None = None
self.last_update_success = False
self.api = protect
@@ -96,47 +88,6 @@ class ProtectData:
"""Max number of events to load at once."""
return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA)
@property
def ignored_macs(self) -> set[str]:
"""Set of ignored MAC addresses."""
if self._ignored_macs is None:
self._ignored_macs = convert_mac_list(
self._entry.options.get(CONF_IGNORED, "")
)
return self._ignored_macs
@callback
def async_get_changed_options(self, entry: ConfigEntry) -> dict[str, Any]:
"""Get changed options for when entry is updated."""
return dict(
set(self._entry.options.items()) - set(self._existing_options.items())
)
@callback
def async_ignore_mac(self, mac: str) -> None:
"""Ignores a MAC address for a UniFi Protect device."""
new_macs = (self._ignored_macs or set()).copy()
new_macs.add(mac)
_LOGGER.debug("Updating ignored_devices option: %s", self.ignored_macs)
options = dict(self._entry.options)
options[CONF_IGNORED] = ",".join(new_macs)
self._hass.config_entries.async_update_entry(self._entry, options=options)
@callback
def async_add_new_ignored_macs(self, new_macs: set[str]) -> None:
"""Add new ignored MAC addresses and ensures the devices are removed."""
for mac in new_macs:
device = self.api.bootstrap.get_device_from_mac(mac)
if device is not None:
self._async_remove_device(device)
self._ignored_macs = None
self._existing_options = dict(self._entry.options)
def get_by_types(
self, device_types: Iterable[ModelType], ignore_unadopted: bool = True
) -> Generator[ProtectAdoptableDeviceModel, None, None]:
@@ -148,8 +99,6 @@ class ProtectData:
for device in devices:
if ignore_unadopted and not device.is_adopted_by_us:
continue
if device.mac in self.ignored_macs:
continue
yield device
async def async_setup(self) -> None:
@@ -159,11 +108,6 @@ class ProtectData:
)
await self.async_refresh()
for mac in self.ignored_macs:
device = self.api.bootstrap.get_device_from_mac(mac)
if device is not None:
self._async_remove_device(device)
async def async_stop(self, *args: Any) -> None:
"""Stop processing data."""
if self._unsub_websocket:
@@ -228,7 +172,6 @@ class ProtectData:
@callback
def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None:
registry = dr.async_get(self._hass)
device_entry = registry.async_get_device(
identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}
@@ -353,13 +296,13 @@ class ProtectData:
@callback
def async_ufp_data_for_config_entry_ids(
def async_ufp_instance_for_config_entry_ids(
hass: HomeAssistant, config_entry_ids: set[str]
) -> ProtectData | None:
) -> ProtectApiClient | None:
"""Find the UFP instance for the config entry ids."""
domain_data = hass.data[DOMAIN]
for config_entry_id in config_entry_ids:
if config_entry_id in domain_data:
protect_data: ProtectData = domain_data[config_entry_id]
return protect_data
return protect_data.api
return None

View File

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

View File

@@ -25,7 +25,7 @@ from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.util.read_only_dict import ReadOnlyDict
from .const import ATTR_MESSAGE, DOMAIN
from .data import async_ufp_data_for_config_entry_ids
from .data import async_ufp_instance_for_config_entry_ids
SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text"
SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text"
@@ -70,8 +70,8 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl
return _async_get_ufp_instance(hass, device_entry.via_device_id)
config_entry_ids = device_entry.config_entries
if ufp_data := async_ufp_data_for_config_entry_ids(hass, config_entry_ids):
return ufp_data.api
if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids):
return ufp_instance
raise HomeAssistantError(f"No device found for device id: {device_id}")

View File

@@ -50,13 +50,9 @@
"disable_rtsp": "Disable the RTSP stream",
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
"override_connection_host": "Override Connection Host",
"max_media": "Max number of event to load for Media Browser (increases RAM usage)",
"ignored_devices": "Comma separated list of MAC addresses of devices to ignore"
"max_media": "Max number of event to load for Media Browser (increases RAM usage)"
}
}
},
"error": {
"invalid_mac_list": "Must be a list of MAC addresses seperated by commas"
}
}
}

View File

@@ -42,15 +42,11 @@
}
},
"options": {
"error": {
"invalid_mac_list": "Must be a list of MAC addresses seperated by commas"
},
"step": {
"init": {
"data": {
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
"disable_rtsp": "Disable the RTSP stream",
"ignored_devices": "Comma separated list of MAC addresses of devices to ignore",
"max_media": "Max number of event to load for Media Browser (increases RAM usage)",
"override_connection_host": "Override Connection Host"
},

View File

@@ -1,9 +1,9 @@
"""UniFi Protect Integration utils."""
from __future__ import annotations
from collections.abc import Generator, Iterable
import contextlib
from enum import Enum
import re
import socket
from typing import Any
@@ -14,16 +14,12 @@ from pyunifiprotect.data import (
LightModeType,
ProtectAdoptableDeviceModel,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, ModelType
MAC_RE = re.compile(r"[0-9A-F]{12}")
def get_nested_attr(obj: Any, attr: str) -> Any:
"""Fetch a nested attribute."""
@@ -42,16 +38,15 @@ def get_nested_attr(obj: Any, attr: str) -> Any:
@callback
def async_unifi_mac(mac: str) -> str:
"""Convert MAC address to format from UniFi Protect."""
def _async_unifi_mac_from_hass(mac: str) -> str:
# MAC addresses in UFP are always caps
return mac.replace(":", "").replace("-", "").replace("_", "").upper()
return mac.replace(":", "").upper()
@callback
def async_short_mac(mac: str) -> str:
def _async_short_mac(mac: str) -> str:
"""Get the short mac address from the full mac."""
return async_unifi_mac(mac)[-6:]
return _async_unifi_mac_from_hass(mac)[-6:]
async def _async_resolve(hass: HomeAssistant, host: str) -> str | None:
@@ -82,6 +77,18 @@ def async_get_devices_by_type(
return devices
@callback
def async_get_devices(
bootstrap: Bootstrap, model_type: Iterable[ModelType]
) -> Generator[ProtectAdoptableDeviceModel, None, None]:
"""Return all device by type."""
return (
device
for device_type in model_type
for device in async_get_devices_by_type(bootstrap, device_type).values()
)
@callback
def async_get_light_motion_current(obj: Light) -> str:
"""Get light motion mode for Flood Light."""
@@ -99,22 +106,3 @@ def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str:
"""Generate entry specific dispatch ID."""
return f"{DOMAIN}.{entry.entry_id}.{dispatch}"
@callback
def convert_mac_list(option: str, raise_exception: bool = False) -> set[str]:
"""Convert csv list of MAC addresses."""
macs = set()
values = cv.ensure_list_csv(option)
for value in values:
if value == "":
continue
value = async_unifi_mac(value)
if not MAC_RE.match(value):
if raise_exception:
raise vol.Invalid("invalid_mac_list")
continue
macs.add(value)
return macs

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

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

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

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

View File

@@ -2,7 +2,7 @@
"domain": "velbus",
"name": "Velbus",
"documentation": "https://www.home-assistant.io/integrations/velbus",
"requirements": ["velbus-aio==2022.6.2"],
"requirements": ["velbus-aio==2022.9.1"],
"config_flow": true,
"codeowners": ["@Cereal2nd", "@brefra"],
"dependencies": ["usb"],

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -610,16 +610,18 @@ class Light(BaseLight, ZhaEntity):
and self._color_channel.enhanced_current_hue is not None
):
curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360
else:
elif self._color_channel.current_hue is not None:
curr_hue = self._color_channel.current_hue * 254 / 360
curr_saturation = self._color_channel.current_saturation
if curr_hue is not None and curr_saturation is not None:
self._attr_hs_color = (
int(curr_hue),
int(curr_saturation * 2.54),
)
else:
self._attr_hs_color = (0, 0)
curr_hue = 0
if (curr_saturation := self._color_channel.current_saturation) is None:
curr_saturation = 0
self._attr_hs_color = (
int(curr_hue),
int(curr_saturation * 2.54),
)
if self._color_channel.color_loop_supported:
self._attr_supported_features |= light.LightEntityFeature.EFFECT

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,315 @@ 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,
)
LOGGER.debug("Node added: %s", node.node_id)
# Listen for ready node events, both new and re-interview.
self.config_entry.async_on_unload(
node.on(
"ready",
lambda event: self.hass.async_create_task(
self.node_events.async_on_node_ready(event["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
# 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)
driver = self.controller_events.driver_events.driver
# 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)
# Remove any old value ids if this is a reinterview.
self.controller_events.discovered_value_ids.pop(device.id, None)
# Remove stale entities that may exist from a previous interview.
async_dispatcher_send(
self.hass,
(
f"{DOMAIN}_"
f"{get_valueless_base_unique_id(driver, node)}_"
"remove_entity_on_ready_node"
),
)
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 +506,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 +533,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 +604,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 +670,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 +717,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 +749,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 +767,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)

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