forked from home-assistant/core
Compare commits
81 Commits
2022.9.0b2
...
2022.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7f3f6ced | ||
|
|
fee9a303ff | ||
|
|
a4f398a750 | ||
|
|
c873eae79c | ||
|
|
d559b6482a | ||
|
|
760853f615 | ||
|
|
cfe8ebdad4 | ||
|
|
2ddd1b516c | ||
|
|
3b025b211e | ||
|
|
4009a32fb5 | ||
|
|
6f3b49601e | ||
|
|
31858ad779 | ||
|
|
ab9d9d599e | ||
|
|
ce6d337bd5 | ||
|
|
3fd887b1f2 | ||
|
|
996a3477b0 | ||
|
|
910f27f3a2 | ||
|
|
4ab5cdcb79 | ||
|
|
e69fde6875 | ||
|
|
10f7e2ff8a | ||
|
|
3acc3af38c | ||
|
|
a3edbfc601 | ||
|
|
941a5e3820 | ||
|
|
2eeab820b7 | ||
|
|
8d0ebdd1f9 | ||
|
|
9901b31316 | ||
|
|
a4f528e908 | ||
|
|
9aa87761cf | ||
|
|
d1b637ea7a | ||
|
|
c8ad8a6d86 | ||
|
|
9155f669e9 | ||
|
|
e1e153f391 | ||
|
|
1dbcf88e15 | ||
|
|
a13438c5b0 | ||
|
|
d98687b789 | ||
|
|
319b0b8902 | ||
|
|
62dcbc4d4a | ||
|
|
6989b16274 | ||
|
|
31d085cdf8 | ||
|
|
61ee621c90 | ||
|
|
f5e61ecdec | ||
|
|
2bfcdc66b6 | ||
|
|
3240f8f938 | ||
|
|
74ddc336ca | ||
|
|
6c36d5acaa | ||
|
|
e8c4711d88 | ||
|
|
bca9dc1f61 | ||
|
|
4f8421617e | ||
|
|
40421b41f7 | ||
|
|
b0ff4fc057 | ||
|
|
605e350159 | ||
|
|
ad8cd9c957 | ||
|
|
e8ab4eef44 | ||
|
|
b1241bf0f2 | ||
|
|
f3e811417f | ||
|
|
1231ba4d03 | ||
|
|
e07554dc25 | ||
|
|
2fa517b81b | ||
|
|
0d042d496d | ||
|
|
c8156d5de6 | ||
|
|
9f06baa778 | ||
|
|
52abf0851b | ||
|
|
da83ceca5b | ||
|
|
f9b95cc4a4 | ||
|
|
f60ae40661 | ||
|
|
ea0b406692 | ||
|
|
9387449abf | ||
|
|
5f4013164c | ||
|
|
3856178dc0 | ||
|
|
32a9fba58e | ||
|
|
9733887b6a | ||
|
|
b215514c90 | ||
|
|
0e930fd626 | ||
|
|
cd4c31bc79 | ||
|
|
bc04755d05 | ||
|
|
041eaf27a9 | ||
|
|
d6a99da461 | ||
|
|
1d2439a6e5 | ||
|
|
6fff633325 | ||
|
|
9652c0c326 | ||
|
|
36c1b9a419 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
49
homeassistant/components/bluemaestro/__init__.py
Normal file
49
homeassistant/components/bluemaestro/__init__.py
Normal 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
|
||||
94
homeassistant/components/bluemaestro/config_flow.py
Normal file
94
homeassistant/components/bluemaestro/config_flow.py
Normal 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)}
|
||||
),
|
||||
)
|
||||
3
homeassistant/components/bluemaestro/const.py
Normal file
3
homeassistant/components/bluemaestro/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the BlueMaestro integration."""
|
||||
|
||||
DOMAIN = "bluemaestro"
|
||||
31
homeassistant/components/bluemaestro/device.py
Normal file
31
homeassistant/components/bluemaestro/device.py
Normal 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
|
||||
16
homeassistant/components/bluemaestro/manifest.json
Normal file
16
homeassistant/components/bluemaestro/manifest.json
Normal 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"
|
||||
}
|
||||
149
homeassistant/components/bluemaestro/sensor.py
Normal file
149
homeassistant/components/bluemaestro/sensor.py
Normal 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)
|
||||
22
homeassistant/components/bluemaestro/strings.json
Normal file
22
homeassistant/components/bluemaestro/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
homeassistant/components/bluemaestro/translations/en.json
Normal file
22
homeassistant/components/bluemaestro/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -54,6 +54,10 @@ if TYPE_CHECKING:
|
||||
|
||||
FILTER_UUIDS: Final = "UUIDs"
|
||||
|
||||
APPLE_MFR_ID: Final = 76
|
||||
APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller
|
||||
APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker
|
||||
APPLE_START_BYTES_WANTED: Final = {APPLE_DEVICE_ID_START_BYTE, APPLE_HOMEKIT_START_BYTE}
|
||||
|
||||
RSSI_SWITCH_THRESHOLD = 6
|
||||
|
||||
@@ -290,6 +294,19 @@ class BluetoothManager:
|
||||
than the source from the history or the timestamp
|
||||
in the history is older than 180s
|
||||
"""
|
||||
|
||||
# Pre-filter noisy apple devices as they can account for 20-35% of the
|
||||
# traffic on a typical network.
|
||||
advertisement_data = service_info.advertisement
|
||||
manufacturer_data = advertisement_data.manufacturer_data
|
||||
if (
|
||||
len(manufacturer_data) == 1
|
||||
and (apple_data := manufacturer_data.get(APPLE_MFR_ID))
|
||||
and apple_data[0] not in APPLE_START_BYTES_WANTED
|
||||
and not advertisement_data.service_data
|
||||
):
|
||||
return
|
||||
|
||||
device = service_info.device
|
||||
connectable = service_info.connectable
|
||||
address = device.address
|
||||
@@ -299,7 +316,6 @@ class BluetoothManager:
|
||||
return
|
||||
|
||||
self._history[address] = service_info
|
||||
advertisement_data = service_info.advertisement
|
||||
source = service_info.source
|
||||
|
||||
if connectable:
|
||||
@@ -311,12 +327,13 @@ class BluetoothManager:
|
||||
|
||||
matched_domains = self._integration_matcher.match_domains(service_info)
|
||||
_LOGGER.debug(
|
||||
"%s: %s %s connectable: %s match: %s",
|
||||
"%s: %s %s connectable: %s match: %s rssi: %s",
|
||||
source,
|
||||
address,
|
||||
advertisement_data,
|
||||
connectable,
|
||||
matched_domains,
|
||||
device.rssi,
|
||||
)
|
||||
|
||||
for match in self._callback_index.match_callbacks(service_info):
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.16.0",
|
||||
"bluetooth-adapters==0.3.3",
|
||||
"bluetooth-auto-recovery==0.3.0"
|
||||
"bluetooth-adapters==0.3.5",
|
||||
"bluetooth-auto-recovery==0.3.2"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,13 @@ from functools import wraps
|
||||
import logging
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
from pybravia import BraviaTV, BraviaTVError, BraviaTVNotFound
|
||||
from pybravia import (
|
||||
BraviaTV,
|
||||
BraviaTVConnectionError,
|
||||
BraviaTVConnectionTimeout,
|
||||
BraviaTVError,
|
||||
BraviaTVNotFound,
|
||||
)
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
@@ -130,6 +136,10 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
|
||||
_LOGGER.debug("Update skipped, Bravia API service is reloading")
|
||||
return
|
||||
raise UpdateFailed("Error communicating with device") from err
|
||||
except (BraviaTVConnectionError, BraviaTVConnectionTimeout):
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
_LOGGER.debug("Update skipped, Bravia TV is off")
|
||||
except BraviaTVError as err:
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "braviatv",
|
||||
"name": "Sony Bravia TV",
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"requirements": ["pybravia==0.2.0"],
|
||||
"requirements": ["pybravia==0.2.2"],
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""The BThome Bluetooth integration."""
|
||||
"""The BTHome Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bthome_ble import BThomeBluetoothDeviceData, SensorUpdate
|
||||
from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate
|
||||
from bthome_ble.parser import EncryptionScheme
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def process_service_info(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
data: BThomeBluetoothDeviceData,
|
||||
data: BTHomeBluetoothDeviceData,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
) -> SensorUpdate:
|
||||
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
|
||||
@@ -40,14 +40,14 @@ def process_service_info(
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up BThome Bluetooth from a config entry."""
|
||||
"""Set up BTHome Bluetooth from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
|
||||
kwargs = {}
|
||||
if bindkey := entry.data.get("bindkey"):
|
||||
kwargs["bindkey"] = bytes.fromhex(bindkey)
|
||||
data = BThomeBluetoothDeviceData(**kwargs)
|
||||
data = BTHomeBluetoothDeviceData(**kwargs)
|
||||
|
||||
coordinator = hass.data.setdefault(DOMAIN, {})[
|
||||
entry.entry_id
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Config flow for BThome Bluetooth integration."""
|
||||
"""Config flow for BTHome Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from bthome_ble import BThomeBluetoothDeviceData as DeviceData
|
||||
from bthome_ble import BTHomeBluetoothDeviceData as DeviceData
|
||||
from bthome_ble.parser import EncryptionScheme
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -34,8 +34,8 @@ def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str:
|
||||
return device.title or device.get_device_name() or discovery_info.name
|
||||
|
||||
|
||||
class BThomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for BThome Bluetooth."""
|
||||
class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for BTHome Bluetooth."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@@ -68,7 +68,7 @@ class BThomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_get_encryption_key(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Enter a bindkey for an encrypted BThome device."""
|
||||
"""Enter a bindkey for an encrypted BTHome device."""
|
||||
assert self._discovery_info
|
||||
assert self._discovered_device
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Constants for the BThome Bluetooth integration."""
|
||||
"""Constants for the BTHome Bluetooth integration."""
|
||||
|
||||
DOMAIN = "bthome"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for BThome Bluetooth devices."""
|
||||
"""Support for BTHome Bluetooth devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from bthome_ble import DeviceKey, SensorDeviceInfo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "bthome",
|
||||
"name": "BThome",
|
||||
"name": "BTHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"bluetooth": [
|
||||
@@ -13,7 +13,7 @@
|
||||
"service_data_uuid": "0000181e-0000-1000-8000-00805f9b34fb"
|
||||
}
|
||||
],
|
||||
"requirements": ["bthome-ble==0.5.2"],
|
||||
"requirements": ["bthome-ble==1.0.0"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@Ernst79"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for BThome sensors."""
|
||||
"""Support for BTHome sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
@@ -202,26 +202,26 @@ async def async_setup_entry(
|
||||
entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BThome BLE sensors."""
|
||||
"""Set up the BTHome BLE sensors."""
|
||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
BThomeBluetoothSensorEntity, async_add_entities
|
||||
BTHomeBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
|
||||
|
||||
class BThomeBluetoothSensorEntity(
|
||||
class BTHomeBluetoothSensorEntity(
|
||||
PassiveBluetoothProcessorEntity[
|
||||
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
|
||||
],
|
||||
SensorEntity,
|
||||
):
|
||||
"""Representation of a BThome BLE sensor."""
|
||||
"""Representation of a BTHome BLE sensor."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
"dhcp",
|
||||
"energy",
|
||||
"frontend",
|
||||
"homeassistant_alerts",
|
||||
"hardware",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_datetime",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Epson",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/epson",
|
||||
"requirements": ["epson-projector==0.4.6"],
|
||||
"requirements": ["epson-projector==0.5.0"],
|
||||
"codeowners": ["@pszafer"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["epson_projector"]
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from epson_projector import Projector
|
||||
from epson_projector import Projector, ProjectorUnavailableError
|
||||
from epson_projector.const import (
|
||||
BACK,
|
||||
BUSY,
|
||||
@@ -20,7 +20,6 @@ from epson_projector.const import (
|
||||
POWER,
|
||||
SOURCE,
|
||||
SOURCE_LIST,
|
||||
STATE_UNAVAILABLE as EPSON_STATE_UNAVAILABLE,
|
||||
TURN_OFF,
|
||||
TURN_ON,
|
||||
VOL_DOWN,
|
||||
@@ -123,11 +122,16 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update state of device."""
|
||||
power_state = await self._projector.get_power()
|
||||
_LOGGER.debug("Projector status: %s", power_state)
|
||||
if not power_state or power_state == EPSON_STATE_UNAVAILABLE:
|
||||
try:
|
||||
power_state = await self._projector.get_power()
|
||||
except ProjectorUnavailableError as ex:
|
||||
_LOGGER.debug("Projector is unavailable: %s", ex)
|
||||
self._attr_available = False
|
||||
return
|
||||
if not power_state:
|
||||
self._attr_available = False
|
||||
return
|
||||
_LOGGER.debug("Projector status: %s", power_state)
|
||||
self._attr_available = True
|
||||
if power_state == EPSON_CODES[POWER]:
|
||||
self._attr_state = STATE_ON
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/flux_led",
|
||||
"requirements": ["flux_led==0.28.31"],
|
||||
"requirements": ["flux_led==0.28.32"],
|
||||
"quality_scale": "platinum",
|
||||
"codeowners": ["@icemanch", "@bdraco"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20220901.0"],
|
||||
"requirements": ["home-assistant-frontend==20220907.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -51,6 +51,7 @@ SETTINGS_TO_REDACT = {
|
||||
"sebExamKey",
|
||||
"sebConfigKey",
|
||||
"kioskPinEnc",
|
||||
"remoteAdminPasswordEnc",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"connectable": false
|
||||
}
|
||||
],
|
||||
"requirements": ["govee-ble==0.17.1"],
|
||||
"requirements": ["govee-ble==0.17.2"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -143,7 +143,6 @@ class HistoryStatsSensorBase(
|
||||
class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
"""A HistoryStats sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.DURATION
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
@@ -157,6 +156,8 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
self._attr_native_unit_of_measurement = UNITS[sensor_type]
|
||||
self._type = sensor_type
|
||||
self._process_update()
|
||||
if self._type == CONF_TYPE_TIME:
|
||||
self._attr_device_class = SensorDeviceClass.DURATION
|
||||
|
||||
@callback
|
||||
def _process_update(self) -> None:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ from homeassistant.const import CONF_NAME, CONF_SHOW_ON_MAP
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .binary_sensor import DEFAULT_NAME
|
||||
from .const import DOMAIN
|
||||
|
||||
DEFAULT_NAME = "ISS"
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for iss component."""
|
||||
@@ -30,10 +31,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Check if location have been defined.
|
||||
if not self.hass.config.latitude and not self.hass.config.longitude:
|
||||
return self.async_abort(reason="latitude_longitude_not_defined")
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_NAME, DEFAULT_NAME),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Support for iss binary sensor."""
|
||||
"""Support for iss sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -19,12 +19,6 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_ISS_NEXT_RISE = "next_rise"
|
||||
ATTR_ISS_NUMBER_PEOPLE_SPACE = "number_of_people_in_space"
|
||||
|
||||
DEFAULT_NAME = "ISS"
|
||||
DEFAULT_DEVICE_CLASS = "visible"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -37,15 +31,11 @@ async def async_setup_entry(
|
||||
name = entry.title
|
||||
show_on_map = entry.options.get(CONF_SHOW_ON_MAP, False)
|
||||
|
||||
async_add_entities([IssBinarySensor(coordinator, name, show_on_map)])
|
||||
async_add_entities([IssSensor(coordinator, name, show_on_map)])
|
||||
|
||||
|
||||
class IssBinarySensor(
|
||||
CoordinatorEntity[DataUpdateCoordinator[IssData]], BinarySensorEntity
|
||||
):
|
||||
"""Implementation of the ISS binary sensor."""
|
||||
|
||||
_attr_device_class = DEFAULT_DEVICE_CLASS
|
||||
class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity):
|
||||
"""Implementation of the ISS sensor."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: DataUpdateCoordinator[IssData], name: str, show: bool
|
||||
@@ -57,17 +47,14 @@ class IssBinarySensor(
|
||||
self._show_on_map = show
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.coordinator.data.is_above is True
|
||||
def native_value(self) -> int:
|
||||
"""Return number of people in space."""
|
||||
return self.coordinator.data.number_of_people_in_space
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
attrs = {
|
||||
ATTR_ISS_NUMBER_PEOPLE_SPACE: self.coordinator.data.number_of_people_in_space,
|
||||
ATTR_ISS_NEXT_RISE: self.coordinator.data.next_rise,
|
||||
}
|
||||
attrs = {}
|
||||
if self._show_on_map:
|
||||
attrs[ATTR_LONGITUDE] = self.coordinator.data.current_location.get(
|
||||
"longitude"
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
70
homeassistant/components/lifx/binary_sensor.py
Normal file
70
homeassistant/components/lifx/binary_sensor.py
Normal 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()
|
||||
@@ -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__)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from .const import DATA_LIFX_MANAGER, DOMAIN
|
||||
from .const import ATTR_INFRARED, ATTR_POWER, ATTR_ZONES, DATA_LIFX_MANAGER, DOMAIN
|
||||
from .coordinator import LIFXUpdateCoordinator
|
||||
from .entity import LIFXEntity
|
||||
from .manager import (
|
||||
@@ -39,13 +39,7 @@ from .manager import (
|
||||
)
|
||||
from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk
|
||||
|
||||
SERVICE_LIFX_SET_STATE = "set_state"
|
||||
|
||||
COLOR_ZONE_POPULATE_DELAY = 0.3
|
||||
|
||||
ATTR_INFRARED = "infrared"
|
||||
ATTR_ZONES = "zones"
|
||||
ATTR_POWER = "power"
|
||||
LIFX_STATE_SETTLE_DELAY = 0.3
|
||||
|
||||
SERVICE_LIFX_SET_STATE = "set_state"
|
||||
|
||||
@@ -237,6 +231,9 @@ class LIFXLight(LIFXEntity, LightEntity):
|
||||
if power_off:
|
||||
await self.set_power(False, duration=fade)
|
||||
|
||||
# Avoid state ping-pong by holding off updates as the state settles
|
||||
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
|
||||
|
||||
# Update when the transition starts and ends
|
||||
await self.update_during_transition(fade)
|
||||
|
||||
@@ -344,7 +341,7 @@ class LIFXStrip(LIFXColor):
|
||||
# Zone brightness is not reported when powered off
|
||||
if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None:
|
||||
await self.set_power(True)
|
||||
await asyncio.sleep(COLOR_ZONE_POPULATE_DELAY)
|
||||
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
|
||||
await self.update_color_zones()
|
||||
await self.set_power(False)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""The Litter-Robot integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -10,65 +10,48 @@ from homeassistant.core import HomeAssistant
|
||||
from .const import DOMAIN
|
||||
from .hub import LitterRobotHub
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
]
|
||||
|
||||
PLATFORMS_BY_TYPE = {
|
||||
LitterRobot: (
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
),
|
||||
LitterRobot3: (
|
||||
Platform.BUTTON,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
),
|
||||
LitterRobot4: (
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
),
|
||||
FeederRobot: (
|
||||
Platform.BUTTON,
|
||||
Robot: (
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
),
|
||||
LitterRobot: (Platform.VACUUM,),
|
||||
LitterRobot3: (Platform.BUTTON,),
|
||||
FeederRobot: (Platform.BUTTON,),
|
||||
}
|
||||
|
||||
|
||||
def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]:
|
||||
"""Get platforms for robots."""
|
||||
return {
|
||||
platform
|
||||
for robot in robots
|
||||
for robot_type, platforms in PLATFORMS_BY_TYPE.items()
|
||||
if isinstance(robot, robot_type)
|
||||
for platform in platforms
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Litter-Robot from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data)
|
||||
await hub.login(load_robots=True)
|
||||
|
||||
platforms: set[str] = set()
|
||||
for robot in hub.account.robots:
|
||||
platforms.update(PLATFORMS_BY_TYPE[type(robot)])
|
||||
if platforms:
|
||||
if platforms := get_platforms_for_robots(hub.account.robots):
|
||||
await hass.config_entries.async_forward_entry_setups(entry, platforms)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id]
|
||||
await hub.account.disconnect()
|
||||
|
||||
platforms = get_platforms_for_robots(hub.account.robots)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ DOMAIN = "renault"
|
||||
CONF_LOCALE = "locale"
|
||||
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = 300 # 5 minutes
|
||||
DEFAULT_SCAN_INTERVAL = 420 # 7 minutes
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "sensibo",
|
||||
"name": "Sensibo",
|
||||
"documentation": "https://www.home-assistant.io/integrations/sensibo",
|
||||
"requirements": ["pysensibo==1.0.18"],
|
||||
"requirements": ["pysensibo==1.0.19"],
|
||||
"config_flow": true,
|
||||
"codeowners": ["@andrey-git", "@gjohansson-ST"],
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarttub",
|
||||
"dependencies": [],
|
||||
"codeowners": ["@mdz"],
|
||||
"requirements": ["python-smarttub==0.0.32"],
|
||||
"requirements": ["python-smarttub==0.0.33"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["smarttub"]
|
||||
|
||||
@@ -8,6 +8,7 @@ import datetime
|
||||
from functools import partial
|
||||
import logging
|
||||
import socket
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from soco import events_asyncio
|
||||
@@ -21,7 +22,7 @@ from homeassistant.components import ssdp
|
||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval, call_later
|
||||
@@ -93,7 +94,7 @@ class SonosData:
|
||||
self.favorites: dict[str, SonosFavorites] = {}
|
||||
self.alarms: dict[str, SonosAlarms] = {}
|
||||
self.topology_condition = asyncio.Condition()
|
||||
self.hosts_heartbeat = None
|
||||
self.hosts_heartbeat: CALLBACK_TYPE | None = None
|
||||
self.discovery_known: set[str] = set()
|
||||
self.boot_counts: dict[str, int] = {}
|
||||
self.mdns_names: dict[str, str] = {}
|
||||
@@ -168,10 +169,10 @@ class SonosDiscoveryManager:
|
||||
self.data = data
|
||||
self.hosts = set(hosts)
|
||||
self.discovery_lock = asyncio.Lock()
|
||||
self._known_invisible = set()
|
||||
self._known_invisible: set[SoCo] = set()
|
||||
self._manual_config_required = bool(hosts)
|
||||
|
||||
async def async_shutdown(self):
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Stop all running tasks."""
|
||||
await self._async_stop_event_listener()
|
||||
self._stop_manual_heartbeat()
|
||||
@@ -236,6 +237,8 @@ class SonosDiscoveryManager:
|
||||
(SonosAlarms, self.data.alarms),
|
||||
(SonosFavorites, self.data.favorites),
|
||||
):
|
||||
if TYPE_CHECKING:
|
||||
coord_dict = cast(dict[str, Any], coord_dict)
|
||||
if soco.household_id not in coord_dict:
|
||||
new_coordinator = coordinator(self.hass, soco.household_id)
|
||||
new_coordinator.setup(soco)
|
||||
@@ -298,7 +301,7 @@ class SonosDiscoveryManager:
|
||||
)
|
||||
|
||||
async def _async_handle_discovery_message(
|
||||
self, uid: str, discovered_ip: str, boot_seqnum: int
|
||||
self, uid: str, discovered_ip: str, boot_seqnum: int | None
|
||||
) -> None:
|
||||
"""Handle discovered player creation and activity."""
|
||||
async with self.discovery_lock:
|
||||
@@ -338,22 +341,27 @@ class SonosDiscoveryManager:
|
||||
async_dispatcher_send(self.hass, f"{SONOS_VANISHED}-{uid}", reason)
|
||||
return
|
||||
|
||||
discovered_ip = urlparse(info.ssdp_location).hostname
|
||||
boot_seqnum = info.ssdp_headers.get("X-RINCON-BOOTSEQ")
|
||||
self.async_discovered_player(
|
||||
"SSDP",
|
||||
info,
|
||||
discovered_ip,
|
||||
cast(str, urlparse(info.ssdp_location).hostname),
|
||||
uid,
|
||||
boot_seqnum,
|
||||
info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME),
|
||||
info.ssdp_headers.get("X-RINCON-BOOTSEQ"),
|
||||
cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)),
|
||||
None,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_discovered_player(
|
||||
self, source, info, discovered_ip, uid, boot_seqnum, model, mdns_name
|
||||
):
|
||||
self,
|
||||
source: str,
|
||||
info: ssdp.SsdpServiceInfo,
|
||||
discovered_ip: str,
|
||||
uid: str,
|
||||
boot_seqnum: str | int | None,
|
||||
model: str,
|
||||
mdns_name: str | None,
|
||||
) -> None:
|
||||
"""Handle discovery via ssdp or zeroconf."""
|
||||
if self._manual_config_required:
|
||||
_LOGGER.warning(
|
||||
@@ -376,10 +384,12 @@ class SonosDiscoveryManager:
|
||||
_LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info)
|
||||
self.data.discovery_known.add(uid)
|
||||
asyncio.create_task(
|
||||
self._async_handle_discovery_message(uid, discovered_ip, boot_seqnum)
|
||||
self._async_handle_discovery_message(
|
||||
uid, discovered_ip, cast(Optional[int], boot_seqnum)
|
||||
)
|
||||
)
|
||||
|
||||
async def setup_platforms_and_discovery(self):
|
||||
async def setup_platforms_and_discovery(self) -> None:
|
||||
"""Set up platforms and discovery."""
|
||||
await self.hass.config_entries.async_forward_entry_setups(self.entry, PLATFORMS)
|
||||
self.entry.async_on_unload(
|
||||
|
||||
@@ -109,6 +109,6 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity):
|
||||
self.speaker.mic_enabled = self.soco.mic_enabled
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
return self.speaker.mic_enabled
|
||||
|
||||
@@ -47,11 +47,11 @@ async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
payload = {"current_timestamp": time.monotonic()}
|
||||
payload: dict[str, Any] = {"current_timestamp": time.monotonic()}
|
||||
|
||||
for section in ("discovered", "discovery_known"):
|
||||
payload[section] = {}
|
||||
data = getattr(hass.data[DATA_SONOS], section)
|
||||
data: set[Any] | dict[str, Any] = getattr(hass.data[DATA_SONOS], section)
|
||||
if isinstance(data, set):
|
||||
payload[section] = data
|
||||
continue
|
||||
@@ -60,7 +60,6 @@ async def async_get_config_entry_diagnostics(
|
||||
payload[section][key] = await async_generate_speaker_info(hass, value)
|
||||
else:
|
||||
payload[section][key] = value
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@@ -85,12 +84,12 @@ async def async_generate_media_info(
|
||||
hass: HomeAssistant, speaker: SonosSpeaker
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a diagnostic payload for current media metadata."""
|
||||
payload = {}
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
for attrib in MEDIA_DIAGNOSTIC_ATTRIBUTES:
|
||||
payload[attrib] = getattr(speaker.media, attrib)
|
||||
|
||||
def poll_current_track_info():
|
||||
def poll_current_track_info() -> dict[str, Any] | str:
|
||||
try:
|
||||
return speaker.soco.avTransport.GetPositionInfo(
|
||||
[("InstanceID", 0), ("Channel", "Master")],
|
||||
@@ -110,9 +109,11 @@ async def async_generate_speaker_info(
|
||||
hass: HomeAssistant, speaker: SonosSpeaker
|
||||
) -> dict[str, Any]:
|
||||
"""Generate the diagnostic payload for a specific speaker."""
|
||||
payload = {}
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
def get_contents(item):
|
||||
def get_contents(
|
||||
item: int | float | str | dict[str, Any]
|
||||
) -> int | float | str | dict[str, Any]:
|
||||
if isinstance(item, (int, float, str)):
|
||||
return item
|
||||
if isinstance(item, dict):
|
||||
|
||||
@@ -20,13 +20,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class SonosHouseholdCoordinator:
|
||||
"""Base class for Sonos household-level storage."""
|
||||
|
||||
cache_update_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, hass: HomeAssistant, household_id: str) -> None:
|
||||
"""Initialize the data."""
|
||||
self.hass = hass
|
||||
self.household_id = household_id
|
||||
self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None
|
||||
self.last_processed_event_id: int | None = None
|
||||
self.cache_update_lock: asyncio.Lock | None = None
|
||||
|
||||
def setup(self, soco: SoCo) -> None:
|
||||
"""Set up the SonosAlarm instance."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ class SynoApi:
|
||||
self.dsm.upgrade.update()
|
||||
except SynologyDSMAPIErrorException as ex:
|
||||
self._with_upgrade = False
|
||||
self.dsm.reset(SynoCoreUpgrade.API_KEY)
|
||||
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
|
||||
|
||||
self._fetch_device_configuration()
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"name": "ThermoPro",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/thermopro",
|
||||
"bluetooth": [{ "local_name": "TP35*", "connectable": false }],
|
||||
"bluetooth": [
|
||||
{ "local_name": "TP35*", "connectable": false },
|
||||
{ "local_name": "TP39*", "connectable": false }
|
||||
],
|
||||
"dependencies": ["bluetooth"],
|
||||
"requirements": ["thermopro-ble==0.4.0"],
|
||||
"requirements": ["thermopro-ble==0.4.3"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -387,8 +387,6 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) ->
|
||||
if gateway_id.endswith("-gateway"):
|
||||
hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"])
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
# Connect to gateway
|
||||
gateway = ConnectXiaomiGateway(hass, entry)
|
||||
try:
|
||||
@@ -444,6 +442,8 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
|
||||
async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Xiaomi Miio device component from a config entry."""
|
||||
@@ -453,10 +453,10 @@ async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
if not platforms:
|
||||
return False
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, platforms)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,15 @@ MODELS_FAN_MIOT = [
|
||||
MODEL_FAN_ZA5,
|
||||
]
|
||||
|
||||
# number of speed levels each fan has
|
||||
SPEEDS_FAN_MIOT = {
|
||||
MODEL_FAN_1C: 3,
|
||||
MODEL_FAN_P10: 4,
|
||||
MODEL_FAN_P11: 4,
|
||||
MODEL_FAN_P9: 4,
|
||||
MODEL_FAN_ZA5: 4,
|
||||
}
|
||||
|
||||
MODELS_PURIFIER_MIOT = [
|
||||
MODEL_AIRPURIFIER_3,
|
||||
MODEL_AIRPURIFIER_3C,
|
||||
|
||||
@@ -85,6 +85,7 @@ from .const import (
|
||||
MODELS_PURIFIER_MIOT,
|
||||
SERVICE_RESET_FILTER,
|
||||
SERVICE_SET_EXTRA_FEATURES,
|
||||
SPEEDS_FAN_MIOT,
|
||||
)
|
||||
from .device import XiaomiCoordinatedMiioEntity
|
||||
|
||||
@@ -234,9 +235,13 @@ async def async_setup_entry(
|
||||
elif model in MODELS_FAN_MIIO:
|
||||
entity = XiaomiFan(device, config_entry, unique_id, coordinator)
|
||||
elif model == MODEL_FAN_ZA5:
|
||||
entity = XiaomiFanZA5(device, config_entry, unique_id, coordinator)
|
||||
speed_count = SPEEDS_FAN_MIOT[model]
|
||||
entity = XiaomiFanZA5(device, config_entry, unique_id, coordinator, speed_count)
|
||||
elif model in MODELS_FAN_MIOT:
|
||||
entity = XiaomiFanMiot(device, config_entry, unique_id, coordinator)
|
||||
speed_count = SPEEDS_FAN_MIOT[model]
|
||||
entity = XiaomiFanMiot(
|
||||
device, config_entry, unique_id, coordinator, speed_count
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
@@ -1044,6 +1049,11 @@ class XiaomiFanP5(XiaomiGenericFan):
|
||||
class XiaomiFanMiot(XiaomiGenericFan):
|
||||
"""Representation of a Xiaomi Fan Miot."""
|
||||
|
||||
def __init__(self, device, entry, unique_id, coordinator, speed_count):
|
||||
"""Initialize MIOT fan with speed count."""
|
||||
super().__init__(device, entry, unique_id, coordinator)
|
||||
self._speed_count = speed_count
|
||||
|
||||
@property
|
||||
def operation_mode_class(self):
|
||||
"""Hold operation mode class."""
|
||||
@@ -1061,7 +1071,9 @@ class XiaomiFanMiot(XiaomiGenericFan):
|
||||
self._preset_mode = self.coordinator.data.mode.name
|
||||
self._oscillating = self.coordinator.data.oscillate
|
||||
if self.coordinator.data.is_on:
|
||||
self._percentage = self.coordinator.data.speed
|
||||
self._percentage = ranged_value_to_percentage(
|
||||
(1, self._speed_count), self.coordinator.data.speed
|
||||
)
|
||||
else:
|
||||
self._percentage = 0
|
||||
|
||||
@@ -1087,16 +1099,22 @@ class XiaomiFanMiot(XiaomiGenericFan):
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
await self._try_command(
|
||||
"Setting fan speed percentage of the miio device failed.",
|
||||
self._device.set_speed,
|
||||
percentage,
|
||||
speed = math.ceil(
|
||||
percentage_to_ranged_value((1, self._speed_count), percentage)
|
||||
)
|
||||
self._percentage = percentage
|
||||
|
||||
# if the fan is not on, we have to turn it on first
|
||||
if not self.is_on:
|
||||
await self.async_turn_on()
|
||||
else:
|
||||
|
||||
result = await self._try_command(
|
||||
"Setting fan speed percentage of the miio device failed.",
|
||||
self._device.set_speed,
|
||||
speed,
|
||||
)
|
||||
|
||||
if result:
|
||||
self._percentage = ranged_value_to_percentage((1, self._speed_count), speed)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "yale_smart_alarm",
|
||||
"name": "Yale Smart Living",
|
||||
"documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm",
|
||||
"requirements": ["yalesmartalarmclient==0.3.8"],
|
||||
"requirements": ["yalesmartalarmclient==0.3.9"],
|
||||
"codeowners": ["@gjohansson-ST"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "zeroconf",
|
||||
"name": "Zero-configuration networking (zeroconf)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
|
||||
"requirements": ["zeroconf==0.39.0"],
|
||||
"requirements": ["zeroconf==0.39.1"],
|
||||
"dependencies": ["network", "api"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"quality_scale": "internal",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user