mirror of
https://github.com/home-assistant/core.git
synced 2025-09-22 19:39:36 +00:00
Compare commits
205 Commits
fix-host-d
...
2023.11.3
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ef89d1cd3d | ||
![]() |
9c4fd88a3d | ||
![]() |
f5783cd3b5 | ||
![]() |
1200ded24c | ||
![]() |
da992e9f45 | ||
![]() |
40326385ae | ||
![]() |
da04c32893 | ||
![]() |
ae2ff926c1 | ||
![]() |
a5d48da07a | ||
![]() |
669daabfdb | ||
![]() |
b64ef24f20 | ||
![]() |
86beb9d135 | ||
![]() |
64297aeb8f | ||
![]() |
5650df5cfb | ||
![]() |
83c59d4154 | ||
![]() |
4680ac0cbf | ||
![]() |
8b79d38497 | ||
![]() |
35b1051c67 | ||
![]() |
fcc7020946 | ||
![]() |
d69d9863b5 | ||
![]() |
885152df81 | ||
![]() |
7ff1bdb098 | ||
![]() |
399299c13c | ||
![]() |
c241c2f79c | ||
![]() |
b010c6b793 | ||
![]() |
2f380d4b75 | ||
![]() |
19f268a1e1 | ||
![]() |
bcd371ac2b | ||
![]() |
a5a8d38d08 | ||
![]() |
56298b2c88 | ||
![]() |
cf35e9b154 | ||
![]() |
29a65d5620 | ||
![]() |
c352cf0bd8 | ||
![]() |
e89b47138d | ||
![]() |
339e9e7b48 | ||
![]() |
92780dd217 | ||
![]() |
6133ce0258 | ||
![]() |
57c76b2ea3 | ||
![]() |
149aef9a12 | ||
![]() |
3dddf6b9f6 | ||
![]() |
2a26dea587 | ||
![]() |
31ac03fe50 | ||
![]() |
fb1dfb016e | ||
![]() |
8a152a68d8 | ||
![]() |
df3e49b24f | ||
![]() |
db604170ba | ||
![]() |
d8a6d3e1bc | ||
![]() |
6f086a27d4 | ||
![]() |
3993c14f1d | ||
![]() |
d63d7841c3 | ||
![]() |
e555671765 | ||
![]() |
a3319262ac | ||
![]() |
eaf711335d | ||
![]() |
f120558750 | ||
![]() |
30dc05cdd7 | ||
![]() |
8ce746972f | ||
![]() |
f946ed9e16 | ||
![]() |
0ffc1bae76 | ||
![]() |
d1a3a5895b | ||
![]() |
f9c70fd3c8 | ||
![]() |
70f0ee81c9 | ||
![]() |
95d4254074 | ||
![]() |
c8d3e377f0 | ||
![]() |
da1c282c1b | ||
![]() |
35c0c9958d | ||
![]() |
93a0bd351a | ||
![]() |
dbdd9d74cf | ||
![]() |
3cac87cf30 | ||
![]() |
d019045199 | ||
![]() |
8f684ab102 | ||
![]() |
c17def27fc | ||
![]() |
27d8d1011e | ||
![]() |
e2270a305d | ||
![]() |
6fd8973a00 | ||
![]() |
9a37868244 | ||
![]() |
9327c51115 | ||
![]() |
e56e75114a | ||
![]() |
f45114371e | ||
![]() |
7e2c12b0a9 | ||
![]() |
050f1085d0 | ||
![]() |
334a02bc2b | ||
![]() |
412fa4c65a | ||
![]() |
2b36befe95 | ||
![]() |
aa623cc15c | ||
![]() |
b0bb91ec08 | ||
![]() |
ce12d82624 | ||
![]() |
9eff9ee374 | ||
![]() |
1ef460cffe | ||
![]() |
42243f1433 | ||
![]() |
8a07c10d88 | ||
![]() |
730a3f7870 | ||
![]() |
718901d2ad | ||
![]() |
d95d4d0184 | ||
![]() |
67ce51899f | ||
![]() |
810681b357 | ||
![]() |
0b0f099d27 | ||
![]() |
4a56d0ec1d | ||
![]() |
910654bf78 | ||
![]() |
1a823376d8 | ||
![]() |
ba634ac346 | ||
![]() |
92486b1ff0 | ||
![]() |
06d26b7c7f | ||
![]() |
1dcd66d75c | ||
![]() |
c811e0db49 | ||
![]() |
dc30ddc24b | ||
![]() |
239fa04d02 | ||
![]() |
2be229c5b5 | ||
![]() |
5b4df0f7ff | ||
![]() |
355b51d4c8 | ||
![]() |
0c8074bab4 | ||
![]() |
acd98e9b40 | ||
![]() |
0b8d4235c3 | ||
![]() |
4ce859b4e4 | ||
![]() |
18acec32b8 | ||
![]() |
cfa2f2ce61 | ||
![]() |
aa5ea5ebc3 | ||
![]() |
bcea021c14 | ||
![]() |
ea2d2ba7b7 | ||
![]() |
c5f21fefbe | ||
![]() |
9910f9e0ae | ||
![]() |
f0a06efa1f | ||
![]() |
8992d15ffc | ||
![]() |
e097dc02dd | ||
![]() |
bfae1468d6 | ||
![]() |
09ed6e9f9b | ||
![]() |
040ecb74e0 | ||
![]() |
a48e63aa28 | ||
![]() |
19479b2a68 | ||
![]() |
9ae29e243d | ||
![]() |
e309bd764b | ||
![]() |
777ffe6946 | ||
![]() |
fa0f679a9a | ||
![]() |
26b7e94c4f | ||
![]() |
957998ea8d | ||
![]() |
abaeacbd6b | ||
![]() |
d76c16fa3a | ||
![]() |
67edb98e59 | ||
![]() |
376a79eb42 | ||
![]() |
41500cbe9b | ||
![]() |
06f27e7e74 | ||
![]() |
a3ebfaebe7 | ||
![]() |
8d781ff063 | ||
![]() |
bac39f0061 | ||
![]() |
c7b702f3c2 | ||
![]() |
3728f3da69 | ||
![]() |
31d8f4b35d | ||
![]() |
f113d9aa71 | ||
![]() |
891ad0b1be | ||
![]() |
5c16a8247a | ||
![]() |
483671bf9f | ||
![]() |
6f73d2aac5 | ||
![]() |
f5b3661836 | ||
![]() |
f70c13214c | ||
![]() |
70e8978123 | ||
![]() |
031b1c26ce | ||
![]() |
13580a334f | ||
![]() |
e81bfb959e | ||
![]() |
fefe930506 | ||
![]() |
5ac7e8b1ac | ||
![]() |
36512f7157 | ||
![]() |
cc3ae9e103 | ||
![]() |
12482216f6 | ||
![]() |
20409d0124 | ||
![]() |
a741bc9951 | ||
![]() |
59d2bce369 | ||
![]() |
eef318f63c | ||
![]() |
9c8a4bb4eb | ||
![]() |
9c9f1ea685 | ||
![]() |
85d999b020 | ||
![]() |
bcddf52364 | ||
![]() |
07e4e1379a | ||
![]() |
f9f010643a | ||
![]() |
974c34e2b6 | ||
![]() |
1c3de76b04 | ||
![]() |
bee63ca654 | ||
![]() |
29c99f419f | ||
![]() |
3d321c5ca7 | ||
![]() |
4617c16a96 | ||
![]() |
a60656bf29 | ||
![]() |
2eb2a65197 | ||
![]() |
867aaf10ee | ||
![]() |
7fe1ac901f | ||
![]() |
5dca3844ef | ||
![]() |
b5c75a2f2f | ||
![]() |
62fc9dfd6c | ||
![]() |
0573981d6f | ||
![]() |
cc7a4d01e3 | ||
![]() |
293025ab6c | ||
![]() |
a490b5e286 | ||
![]() |
7e4da1d03b | ||
![]() |
9e140864eb | ||
![]() |
a6f88fb123 | ||
![]() |
386c5ecc3e | ||
![]() |
0d7fb5b026 | ||
![]() |
767b7ba4d6 | ||
![]() |
f2cef7245a | ||
![]() |
701a5d7758 | ||
![]() |
244fccdae6 | ||
![]() |
10e6a26717 | ||
![]() |
5fe5013198 | ||
![]() |
0a0584b053 | ||
![]() |
62733e830f | ||
![]() |
bbcfb5f30e | ||
![]() |
5b0e0b07b3 | ||
![]() |
05fd64fe80 |
@@ -45,6 +45,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/switch/**
|
||||
- homeassistant/components/text/**
|
||||
- homeassistant/components/time/**
|
||||
- homeassistant/components/todo/**
|
||||
- homeassistant/components/tts/**
|
||||
- homeassistant/components/update/**
|
||||
- homeassistant/components/vacuum/**
|
||||
|
@@ -19,6 +19,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
|
||||
|
||||
# These are events that do not contain any sensitive data
|
||||
# Except for state_changed, which is handled accordingly.
|
||||
@@ -28,6 +29,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = {
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_DEVICE_REGISTRY_UPDATED,
|
||||
EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
EVENT_PANELS_UPDATED,
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
|
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["jaraco.abode", "lomond"],
|
||||
"requirements": ["jaraco.abode==3.3.0"]
|
||||
"requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"]
|
||||
}
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["accuweather==2.0.0"]
|
||||
"requirements": ["accuweather==2.1.0"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.3.0"]
|
||||
"requirements": ["aioairzone-cloud==0.3.5"]
|
||||
}
|
||||
|
@@ -857,16 +857,18 @@ class AlexaInputController(AlexaCapability):
|
||||
|
||||
def inputs(self) -> list[dict[str, str]] | None:
|
||||
"""Return the list of valid supported inputs."""
|
||||
source_list: list[str] = self.entity.attributes.get(
|
||||
source_list: list[Any] = self.entity.attributes.get(
|
||||
media_player.ATTR_INPUT_SOURCE_LIST, []
|
||||
)
|
||||
return AlexaInputController.get_valid_inputs(source_list)
|
||||
|
||||
@staticmethod
|
||||
def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]:
|
||||
def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]:
|
||||
"""Return list of supported inputs."""
|
||||
input_list: list[dict[str, str]] = []
|
||||
for source in source_list:
|
||||
if not isinstance(source, str):
|
||||
continue
|
||||
formatted_source = (
|
||||
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
)
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"loggers": ["adb_shell", "androidtv", "pure_python_adb"],
|
||||
"requirements": [
|
||||
"adb-shell[async]==0.4.4",
|
||||
"androidtv[async]==0.0.72",
|
||||
"androidtv[async]==0.0.73",
|
||||
"pure-python-adb[async]==0.3.0.dev0"
|
||||
]
|
||||
}
|
||||
|
@@ -21,6 +21,15 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
COMMAND_TO_ATTRIBUTE = {
|
||||
"wakeup": ("power", "turn_on"),
|
||||
"suspend": ("power", "turn_off"),
|
||||
"turn_on": ("power", "turn_on"),
|
||||
"turn_off": ("power", "turn_off"),
|
||||
"volume_up": ("audio", "volume_up"),
|
||||
"volume_down": ("audio", "volume_down"),
|
||||
"home_hold": ("remote_control", "home"),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
|
||||
for _ in range(num_repeats):
|
||||
for single_command in command:
|
||||
attr_value = getattr(self.atv.remote_control, single_command, None)
|
||||
attr_value = None
|
||||
if attributes := COMMAND_TO_ATTRIBUTE.get(single_command):
|
||||
attr_value = self.atv
|
||||
for attr_name in attributes:
|
||||
attr_value = getattr(attr_value, attr_name, None)
|
||||
if not attr_value:
|
||||
attr_value = getattr(self.atv.remote_control, single_command, None)
|
||||
if not attr_value:
|
||||
raise ValueError("Command not found. Exiting sequence")
|
||||
|
||||
|
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"]
|
||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"]
|
||||
}
|
||||
|
@@ -86,8 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
blink.auth = Auth(auth_data, no_prompt=True, session=session)
|
||||
blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
coordinator = BlinkUpdateCoordinator(hass, blink)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
try:
|
||||
await blink.start()
|
||||
@@ -101,6 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if not blink.available:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/blink",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.22.2"]
|
||||
"requirements": ["blinkpy==0.22.3"]
|
||||
}
|
||||
|
@@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
prev_manufacturer_data = prev_advertisement.manufacturer_data
|
||||
prev_name = prev_device.name
|
||||
|
||||
if local_name and prev_name and len(prev_name) > len(local_name):
|
||||
if prev_name and (not local_name or len(prev_name) > len(local_name)):
|
||||
local_name = prev_name
|
||||
|
||||
if service_uuids and service_uuids != prev_service_uuids:
|
||||
|
@@ -124,6 +124,7 @@ class BluetoothManager:
|
||||
"storage",
|
||||
"slot_manager",
|
||||
"_debug",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -165,6 +166,7 @@ class BluetoothManager:
|
||||
self.storage = storage
|
||||
self.slot_manager = slot_manager
|
||||
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
self.shutdown = False
|
||||
|
||||
@property
|
||||
def supports_passive_scan(self) -> bool:
|
||||
@@ -259,6 +261,7 @@ class BluetoothManager:
|
||||
def async_stop(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
_LOGGER.debug("Stopping bluetooth manager")
|
||||
self.shutdown = True
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
|
@@ -15,10 +15,10 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.21.1",
|
||||
"bleak-retry-connector==3.2.1",
|
||||
"bleak-retry-connector==3.3.0",
|
||||
"bluetooth-adapters==0.16.1",
|
||||
"bluetooth-auto-recovery==1.2.3",
|
||||
"bluetooth-data-tools==1.13.0",
|
||||
"bluetooth-data-tools==1.14.0",
|
||||
"dbus-fast==2.12.0"
|
||||
]
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_IDENTIFIERS,
|
||||
ATTR_NAME,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
@@ -16,7 +17,7 @@ from homeassistant.const import (
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import async_get_current_platform
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -644,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce
|
||||
self._attr_unique_id = f"{address}-{key}"
|
||||
if ATTR_NAME not in self._attr_device_info:
|
||||
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
||||
if device_id is None:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)}
|
||||
self._attr_name = processor.entity_names.get(entity_key)
|
||||
|
||||
@property
|
||||
|
@@ -270,6 +270,10 @@ class HaBleakClientWrapper(BleakClient):
|
||||
"""Connect to the specified GATT server."""
|
||||
assert models.MANAGER is not None
|
||||
manager = models.MANAGER
|
||||
if manager.shutdown:
|
||||
raise BleakError("Bluetooth is already shutdown")
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
|
||||
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
|
||||
device = wrapped_backend.device
|
||||
scanner = wrapped_backend.scanner
|
||||
@@ -281,12 +285,14 @@ class HaBleakClientWrapper(BleakClient):
|
||||
timeout=self.__timeout,
|
||||
hass=manager.hass,
|
||||
)
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
if debug_logging:
|
||||
# Only lookup the description if we are going to log it
|
||||
description = ble_device_description(device)
|
||||
_, adv = scanner.discovered_devices_and_advertisement_data[device.address]
|
||||
rssi = adv.rssi
|
||||
_LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi)
|
||||
_LOGGER.debug(
|
||||
"%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi
|
||||
)
|
||||
connected = None
|
||||
try:
|
||||
connected = await super().connect(**kwargs)
|
||||
@@ -301,7 +307,9 @@ class HaBleakClientWrapper(BleakClient):
|
||||
manager.async_release_connection_slot(device)
|
||||
|
||||
if debug_logging:
|
||||
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)
|
||||
_LOGGER.debug(
|
||||
"%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi
|
||||
)
|
||||
return connected
|
||||
|
||||
@hass_callback
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected==0.14.2"]
|
||||
"requirements": ["bimmer-connected==0.14.3"]
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.2.57"],
|
||||
"requirements": ["boschshcpy==0.2.75"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@@ -24,7 +24,7 @@
|
||||
"location": {
|
||||
"name": "Location"
|
||||
},
|
||||
"messages": {
|
||||
"message": {
|
||||
"name": "Message"
|
||||
},
|
||||
"start_time": {
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==13.0.7"],
|
||||
"requirements": ["PyChromecast==13.0.8"],
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
@@ -17,6 +17,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from . import get_accounts
|
||||
from .const import (
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
@@ -81,7 +82,7 @@ async def validate_options(
|
||||
accounts = await hass.async_add_executor_job(get_accounts, client)
|
||||
|
||||
accounts_currencies = [
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
for account in accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
]
|
||||
|
@@ -12,14 +12,16 @@ DOMAIN = "coinbase"
|
||||
API_ACCOUNT_AMOUNT = "amount"
|
||||
API_ACCOUNT_BALANCE = "balance"
|
||||
API_ACCOUNT_CURRENCY = "currency"
|
||||
API_ACCOUNT_CURRENCY_CODE = "code"
|
||||
API_ACCOUNT_ID = "id"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "native_balance"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
||||
API_ACCOUNT_NAME = "name"
|
||||
API_ACCOUNTS_DATA = "data"
|
||||
API_RATES = "rates"
|
||||
API_RESOURCE_PATH = "resource_path"
|
||||
API_RESOURCE_TYPE = "type"
|
||||
API_TYPE_VAULT = "vault"
|
||||
API_USD = "USD"
|
||||
|
||||
WALLETS = {
|
||||
"1INCH": "1INCH",
|
||||
|
@@ -14,9 +14,9 @@ from .const import (
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNT_NAME,
|
||||
API_ACCOUNT_NATIVE_BALANCE,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
provided_currencies: list[str] = [
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
for account in instance.accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
]
|
||||
@@ -106,26 +106,28 @@ class AccountSensor(SensorEntity):
|
||||
self._currency = currency
|
||||
for account in coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY] != currency
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
continue
|
||||
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
||||
self._attr_unique_id = (
|
||||
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
|
||||
f"{account[API_ACCOUNT_CURRENCY]}"
|
||||
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
|
||||
)
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
]
|
||||
self._attr_icon = CURRENCY_ICONS.get(
|
||||
account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
|
||||
DEFAULT_COIN_ICON,
|
||||
)
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
|
||||
2,
|
||||
)
|
||||
self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_AMOUNT
|
||||
]
|
||||
self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_CURRENCY
|
||||
]
|
||||
break
|
||||
|
||||
self._attr_state_class = SensorStateClass.TOTAL
|
||||
@@ -141,7 +143,7 @@ class AccountSensor(SensorEntity):
|
||||
def extra_state_attributes(self) -> dict[str, str]:
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}",
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
@@ -149,17 +151,17 @@ class AccountSensor(SensorEntity):
|
||||
self._coinbase_data.update()
|
||||
for account in self._coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY] != self._currency
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
!= self._currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
continue
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_AMOUNT
|
||||
]
|
||||
self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][
|
||||
API_ACCOUNT_CURRENCY
|
||||
]
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
2,
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
|
@@ -109,7 +109,7 @@ class ComelitCoverEntity(
|
||||
if not self.is_closing and not self.is_opening:
|
||||
return
|
||||
|
||||
action = STATE_OFF if self.is_closing else STATE_ON
|
||||
action = STATE_ON if self.is_closing else STATE_OFF
|
||||
await self._api.set_device_status(COVER, self._device.index, action)
|
||||
|
||||
@callback
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/comelit",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"requirements": ["aiocomelit==0.3.0"]
|
||||
"requirements": ["aiocomelit==0.5.2"]
|
||||
}
|
||||
|
@@ -5,9 +5,9 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||
@@ -55,31 +55,42 @@ async def async_validate_device_automation_config(
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, validated_config[CONF_DOMAIN], automation_type
|
||||
)
|
||||
|
||||
# Make sure the referenced device and optional entity exist
|
||||
device_registry = dr.async_get(hass)
|
||||
if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])):
|
||||
# The device referenced by the device automation does not exist
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown device '{validated_config[CONF_DEVICE_ID]}'"
|
||||
)
|
||||
if entity_id := validated_config.get(CONF_ENTITY_ID):
|
||||
try:
|
||||
er.async_validate_entity_id(er.async_get(hass), entity_id)
|
||||
except vol.Invalid as err:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown entity '{entity_id}'"
|
||||
) from err
|
||||
|
||||
if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]):
|
||||
# Pass the unvalidated config to avoid mutating the raw config twice
|
||||
return cast(
|
||||
ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config)
|
||||
)
|
||||
|
||||
# Bypass checks for entity platforms
|
||||
# Devices are not linked to config entries from entity platform domains, skip
|
||||
# the checks below which look for a config entry matching the device automation
|
||||
# domain
|
||||
if (
|
||||
automation_type == DeviceAutomationType.ACTION
|
||||
and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS
|
||||
):
|
||||
# Pass the unvalidated config to avoid mutating the raw config twice
|
||||
return cast(
|
||||
ConfigType,
|
||||
await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config),
|
||||
)
|
||||
|
||||
# Only call the dynamic validator if the referenced device exists and the relevant
|
||||
# config entry is loaded
|
||||
registry = dr.async_get(hass)
|
||||
if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])):
|
||||
# The device referenced by the device automation does not exist
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Unknown device '{validated_config[CONF_DEVICE_ID]}'"
|
||||
)
|
||||
|
||||
# Find a config entry with the same domain as the device automation
|
||||
device_config_entry = None
|
||||
for entry_id in device.config_entries:
|
||||
if (
|
||||
@@ -91,7 +102,7 @@ async def async_validate_device_automation_config(
|
||||
break
|
||||
|
||||
if not device_config_entry:
|
||||
# The config entry referenced by the device automation does not exist
|
||||
# There's no config entry with the same domain as the device automation
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from "
|
||||
f"domain '{validated_config[CONF_DOMAIN]}'"
|
||||
|
@@ -25,9 +25,9 @@ see:
|
||||
gps_accuracy:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
min: 0
|
||||
mode: box
|
||||
unit_of_measurement: "m"
|
||||
battery:
|
||||
selector:
|
||||
number:
|
||||
|
@@ -53,6 +53,8 @@ class DSMRConnection:
|
||||
self._protocol = protocol
|
||||
self._telegram: dict[str, DSMRObject] = {}
|
||||
self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5B":
|
||||
self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5L":
|
||||
self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "Q3D":
|
||||
|
@@ -34,6 +34,3 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
|
||||
|
||||
DSMR_PROTOCOL = "dsmr_protocol"
|
||||
RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol"
|
||||
|
||||
# Temp obis until sensors replaced by mbus variants
|
||||
BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n"
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.3.0"]
|
||||
"requirements": ["dsmr-parser==1.3.1"]
|
||||
}
|
||||
|
@@ -44,7 +44,6 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
BELGIUM_5MIN_GAS_METER_READING,
|
||||
CONF_DSMR_VERSION,
|
||||
CONF_PRECISION,
|
||||
CONF_PROTOCOL,
|
||||
@@ -382,16 +381,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_5min_gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=BELGIUM_5MIN_GAS_METER_READING,
|
||||
dsmr_versions={"5B"},
|
||||
is_gas=True,
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
@@ -405,6 +394,31 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription:
|
||||
"""Return correct entity for 5B Gas meter."""
|
||||
ref = None
|
||||
if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS2_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS3_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS4_METER_READING2
|
||||
elif ref is None:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
return DSMRSensorEntityDescription(
|
||||
key="belgium_5min_gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=ref,
|
||||
dsmr_versions={"5B"},
|
||||
is_gas=True,
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
@@ -438,6 +452,10 @@ async def async_setup_entry(
|
||||
return (entity_description.device_class, UNIT_CONVERSION[uom])
|
||||
return (entity_description.device_class, uom)
|
||||
|
||||
all_sensors = SENSORS
|
||||
if dsmr_version == "5B":
|
||||
all_sensors += (add_gas_sensor_5B(telegram),)
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
DSMREntity(
|
||||
@@ -448,7 +466,7 @@ async def async_setup_entry(
|
||||
telegram, description
|
||||
), # type: ignore[arg-type]
|
||||
)
|
||||
for description in SENSORS
|
||||
for description in all_sensors
|
||||
if (
|
||||
description.dsmr_versions is None
|
||||
or dsmr_version in description.dsmr_versions
|
||||
|
@@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
translation_key="gas_meter_usage",
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:fire",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -283,6 +284,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/day-consumption/gas",
|
||||
translation_key="daily_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
@@ -460,6 +462,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/current-month/gas",
|
||||
translation_key="current_month_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
@@ -538,6 +541,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/current-year/gas",
|
||||
translation_key="current_year_gas_usage",
|
||||
icon="mdi:counter",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
|
@@ -5,5 +5,6 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecoforest",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyecoforest"],
|
||||
"requirements": ["pyecoforest==0.3.0"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.1.20"]
|
||||
"requirements": ["pyeconet==0.1.22"]
|
||||
}
|
||||
|
@@ -676,19 +676,20 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]:
|
||||
@lru_cache(maxsize=512)
|
||||
def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
"""Build a state dict for an entity."""
|
||||
is_on = entity.state != STATE_OFF
|
||||
data: dict[str, Any] = {
|
||||
STATE_ON: entity.state != STATE_OFF,
|
||||
STATE_ON: is_on,
|
||||
STATE_BRIGHTNESS: None,
|
||||
STATE_HUE: None,
|
||||
STATE_SATURATION: None,
|
||||
STATE_COLOR_TEMP: None,
|
||||
}
|
||||
if data[STATE_ON]:
|
||||
attributes = entity.attributes
|
||||
if is_on:
|
||||
data[STATE_BRIGHTNESS] = hass_to_hue_brightness(
|
||||
entity.attributes.get(ATTR_BRIGHTNESS, 0)
|
||||
attributes.get(ATTR_BRIGHTNESS) or 0
|
||||
)
|
||||
hue_sat = entity.attributes.get(ATTR_HS_COLOR)
|
||||
if hue_sat is not None:
|
||||
if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None:
|
||||
hue = hue_sat[0]
|
||||
sat = hue_sat[1]
|
||||
# Convert hass hs values back to hue hs values
|
||||
@@ -697,7 +698,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
else:
|
||||
data[STATE_HUE] = HUE_API_STATE_HUE_MIN
|
||||
data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN
|
||||
data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0)
|
||||
data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0
|
||||
|
||||
else:
|
||||
data[STATE_BRIGHTNESS] = 0
|
||||
@@ -706,25 +707,23 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
|
||||
data[STATE_COLOR_TEMP] = 0
|
||||
|
||||
if entity.domain == climate.DOMAIN:
|
||||
temperature = entity.attributes.get(ATTR_TEMPERATURE, 0)
|
||||
temperature = attributes.get(ATTR_TEMPERATURE, 0)
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == humidifier.DOMAIN:
|
||||
humidity = entity.attributes.get(ATTR_HUMIDITY, 0)
|
||||
humidity = attributes.get(ATTR_HUMIDITY, 0)
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == media_player.DOMAIN:
|
||||
level = entity.attributes.get(
|
||||
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0
|
||||
)
|
||||
level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0)
|
||||
# Convert 0.0-1.0 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX)
|
||||
elif entity.domain == fan.DOMAIN:
|
||||
percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0
|
||||
percentage = attributes.get(ATTR_PERCENTAGE) or 0
|
||||
# Convert 0-100 to 0-254
|
||||
data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100)
|
||||
elif entity.domain == cover.DOMAIN:
|
||||
level = entity.attributes.get(ATTR_CURRENT_POSITION, 0)
|
||||
level = attributes.get(ATTR_CURRENT_POSITION, 0)
|
||||
data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX)
|
||||
_clamp_values(data)
|
||||
return data
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.13.1"],
|
||||
"requirements": ["pyenphase==1.14.3"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@@ -75,15 +75,13 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType:
|
||||
self: ESPHomeClient, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
# pylint: disable=protected-access
|
||||
if not self._is_connected:
|
||||
raise BleakError(f"{self._description} is not connected")
|
||||
loop = self._loop
|
||||
disconnected_futures = self._disconnected_futures
|
||||
disconnected_future = loop.create_future()
|
||||
disconnected_futures.add(disconnected_future)
|
||||
ble_device = self._ble_device
|
||||
disconnect_message = (
|
||||
f"{self._source_name }: {ble_device.name} - {ble_device.address}: "
|
||||
"Disconnected during operation"
|
||||
)
|
||||
disconnect_message = f"{self._description}: Disconnected during operation"
|
||||
try:
|
||||
async with interrupt(disconnected_future, BleakError, disconnect_message):
|
||||
return await func(self, *args, **kwargs)
|
||||
@@ -115,10 +113,8 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
|
||||
if ex.error.error == -1:
|
||||
# pylint: disable=protected-access
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected during %s operation",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: BLE device disconnected during %s operation",
|
||||
self._description,
|
||||
func.__name__,
|
||||
)
|
||||
self._async_ble_device_disconnected()
|
||||
@@ -140,7 +136,7 @@ class ESPHomeClientData:
|
||||
api_version: APIVersion
|
||||
title: str
|
||||
scanner: ESPHomeScanner | None
|
||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set)
|
||||
|
||||
|
||||
class ESPHomeClient(BaseBleakClient):
|
||||
@@ -159,10 +155,11 @@ class ESPHomeClient(BaseBleakClient):
|
||||
assert isinstance(address_or_ble_device, BLEDevice)
|
||||
super().__init__(address_or_ble_device, *args, **kwargs)
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._ble_device = address_or_ble_device
|
||||
self._address_as_int = mac_to_int(self._ble_device.address)
|
||||
assert self._ble_device.details is not None
|
||||
self._source = self._ble_device.details["source"]
|
||||
ble_device = address_or_ble_device
|
||||
self._ble_device = ble_device
|
||||
self._address_as_int = mac_to_int(ble_device.address)
|
||||
assert ble_device.details is not None
|
||||
self._source = ble_device.details["source"]
|
||||
self._cache = client_data.cache
|
||||
self._bluetooth_device = client_data.bluetooth_device
|
||||
self._client = client_data.client
|
||||
@@ -177,8 +174,11 @@ class ESPHomeClient(BaseBleakClient):
|
||||
self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||
client_data.api_version
|
||||
)
|
||||
self._address_type = address_or_ble_device.details["address_type"]
|
||||
self._address_type = ble_device.details["address_type"]
|
||||
self._source_name = f"{client_data.title} [{self._source}]"
|
||||
self._description = (
|
||||
f"{self._source_name}: {ble_device.name} - {ble_device.address}"
|
||||
)
|
||||
scanner = client_data.scanner
|
||||
assert scanner is not None
|
||||
self._scanner = scanner
|
||||
@@ -196,12 +196,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
except (AssertionError, ValueError) as ex:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s: %s - %s: Failed to unsubscribe from connection state (likely"
|
||||
"%s: Failed to unsubscribe from connection state (likely"
|
||||
" connection dropped): %s"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
self._description,
|
||||
ex,
|
||||
)
|
||||
self._cancel_connection_state = None
|
||||
@@ -217,6 +215,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
self._disconnected_futures.clear()
|
||||
self._disconnect_callbacks.discard(self._async_esp_disconnected)
|
||||
self._unsubscribe_connection_state()
|
||||
|
||||
def _async_ble_device_disconnected(self) -> None:
|
||||
@@ -224,23 +223,15 @@ class ESPHomeClient(BaseBleakClient):
|
||||
was_connected = self._is_connected
|
||||
self._async_disconnected_cleanup()
|
||||
if was_connected:
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: BLE device disconnected", self._description)
|
||||
self._async_call_bleak_disconnected_callback()
|
||||
|
||||
def _async_esp_disconnected(self) -> None:
|
||||
"""Handle the esp32 client disconnecting from us."""
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: ESP device disconnected",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self._disconnect_callbacks.remove(self._async_esp_disconnected)
|
||||
_LOGGER.debug("%s: ESP device disconnected", self._description)
|
||||
# Calling _async_ble_device_disconnected calls
|
||||
# _async_disconnected_cleanup which will also remove
|
||||
# the disconnect callbacks
|
||||
self._async_ble_device_disconnected()
|
||||
|
||||
def _async_call_bleak_disconnected_callback(self) -> None:
|
||||
@@ -258,10 +249,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
) -> None:
|
||||
"""Handle a connect or disconnect."""
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||
self._description,
|
||||
connected,
|
||||
mtu,
|
||||
error,
|
||||
@@ -300,12 +289,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: connected, registering for disconnected callbacks",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: connected, registering for disconnected callbacks",
|
||||
self._description,
|
||||
)
|
||||
self._disconnect_callbacks.append(self._async_esp_disconnected)
|
||||
self._disconnect_callbacks.add(self._async_esp_disconnected)
|
||||
connected_future.set_result(connected)
|
||||
|
||||
@api_error_as_bleak_error
|
||||
@@ -403,10 +390,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if bluetooth_device.ble_connections_free:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Out of connection slots, waiting for a free one",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Out of connection slots, waiting for a free one",
|
||||
self._description,
|
||||
)
|
||||
async with asyncio.timeout(timeout):
|
||||
await bluetooth_device.wait_for_ble_connections_free()
|
||||
@@ -434,7 +419,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.paired:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Pairing with %s failed due to error: %s", self.address, response.error
|
||||
"%s: Pairing failed due to error: %s", self._description, response.error
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -451,7 +436,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.success:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Unpairing with %s failed due to error: %s", self.address, response.error
|
||||
"%s: Unpairing failed due to error: %s", self._description, response.error
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -486,30 +471,14 @@ class ESPHomeClient(BaseBleakClient):
|
||||
self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
||||
or dangerous_use_bleak_cache
|
||||
) and (cached_services := cache.get_gatt_services_cache(address_as_int)):
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services hit",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services hit", self._description)
|
||||
self.services = cached_services
|
||||
return self.services
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services miss",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services miss", self._description)
|
||||
esphome_services = await self._client.bluetooth_gatt_get_services(
|
||||
address_as_int
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Got services: %s",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
esphome_services,
|
||||
)
|
||||
_LOGGER.debug("%s: Got services: %s", self._description, esphome_services)
|
||||
max_write_without_response = self.mtu_size - GATT_HEADER_SIZE
|
||||
services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
|
||||
for service in esphome_services.services:
|
||||
@@ -538,12 +507,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
raise BleakError("Failed to get services from remote esp")
|
||||
|
||||
self.services = services
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Cached services saved",
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
_LOGGER.debug("%s: Cached services saved", self._description)
|
||||
cache.set_gatt_services_cache(address_as_int, services)
|
||||
return services
|
||||
|
||||
@@ -552,13 +516,15 @@ class ESPHomeClient(BaseBleakClient):
|
||||
) -> BleakGATTCharacteristic:
|
||||
"""Resolve a characteristic specifier to a BleakGATTCharacteristic object."""
|
||||
if (services := self.services) is None:
|
||||
raise BleakError("Services have not been resolved")
|
||||
raise BleakError(f"{self._description}: Services have not been resolved")
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakError(f"Characteristic {char_specifier} was not found!")
|
||||
raise BleakError(
|
||||
f"{self._description}: Characteristic {char_specifier} was not found!"
|
||||
)
|
||||
return characteristic
|
||||
|
||||
@verify_connected
|
||||
@@ -579,8 +545,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if response.success:
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Clear cache failed with %s failed due to error: %s",
|
||||
self.address,
|
||||
"%s: Clear cache failed due to error: %s",
|
||||
self._description,
|
||||
response.error,
|
||||
)
|
||||
return False
|
||||
@@ -692,7 +658,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
ble_handle = characteristic.handle
|
||||
if ble_handle in self._notify_cancels:
|
||||
raise BleakError(
|
||||
"Notifications are already enabled on "
|
||||
f"{self._description}: Notifications are already enabled on "
|
||||
f"service:{characteristic.service_uuid} "
|
||||
f"characteristic:{characteristic.uuid} "
|
||||
f"handle:{ble_handle}"
|
||||
@@ -702,8 +668,8 @@ class ESPHomeClient(BaseBleakClient):
|
||||
and "indicate" not in characteristic.properties
|
||||
):
|
||||
raise BleakError(
|
||||
f"Characteristic {characteristic.uuid} does not have notify or indicate"
|
||||
" property set."
|
||||
f"{self._description}: Characteristic {characteristic.uuid} "
|
||||
"does not have notify or indicate property set."
|
||||
)
|
||||
|
||||
self._notify_cancels[
|
||||
@@ -725,18 +691,13 @@ class ESPHomeClient(BaseBleakClient):
|
||||
cccd_descriptor = characteristic.get_descriptor(CCCD_UUID)
|
||||
if not cccd_descriptor:
|
||||
raise BleakError(
|
||||
f"Characteristic {characteristic.uuid} does not have a "
|
||||
"characteristic client config descriptor."
|
||||
f"{self._description}: Characteristic {characteristic.uuid} "
|
||||
"does not have a characteristic client config descriptor."
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s: %s - %s: Writing to CCD descriptor %s for notifications with"
|
||||
" properties=%s"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
"%s: Writing to CCD descriptor %s for notifications with properties=%s",
|
||||
self._description,
|
||||
cccd_descriptor.handle,
|
||||
characteristic.properties,
|
||||
)
|
||||
@@ -774,12 +735,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if self._cancel_connection_state:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s: %s - %s: ESPHomeClient bleak client was not properly"
|
||||
"%s: ESPHomeClient bleak client was not properly"
|
||||
" disconnected before destruction"
|
||||
),
|
||||
self._source_name,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
self._description,
|
||||
)
|
||||
if not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._async_disconnected_cleanup)
|
||||
|
@@ -107,7 +107,7 @@ class RuntimeEntryData:
|
||||
bluetooth_device: ESPHomeBluetoothDevice | None = None
|
||||
api_version: APIVersion = field(default_factory=APIVersion)
|
||||
cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||
disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set)
|
||||
state_subscriptions: dict[
|
||||
tuple[type[EntityState], int], Callable[[], None]
|
||||
] = field(default_factory=dict)
|
||||
@@ -427,3 +427,19 @@ class RuntimeEntryData:
|
||||
if self.original_options == entry.options:
|
||||
return
|
||||
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
|
||||
|
||||
@callback
|
||||
def async_on_disconnect(self) -> None:
|
||||
"""Call when the entry has been disconnected.
|
||||
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
self.available = False
|
||||
# Make a copy since calling the disconnect callbacks
|
||||
# may also try to discard/remove themselves.
|
||||
for disconnect_cb in self.disconnect_callbacks.copy():
|
||||
disconnect_cb()
|
||||
# Make sure to clear the set to give up the reference
|
||||
# to it and make sure all the callbacks can be GC'd.
|
||||
self.disconnect_callbacks.clear()
|
||||
self.disconnect_callbacks = set()
|
||||
|
@@ -294,7 +294,7 @@ class ESPHomeManager:
|
||||
event.data["entity_id"], attribute, new_state
|
||||
)
|
||||
|
||||
self.entry_data.disconnect_callbacks.append(
|
||||
self.entry_data.disconnect_callbacks.add(
|
||||
async_track_state_change_event(
|
||||
hass, [entity_id], send_home_assistant_state_event
|
||||
)
|
||||
@@ -439,7 +439,7 @@ class ESPHomeManager:
|
||||
reconnect_logic.name = device_info.name
|
||||
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version):
|
||||
entry_data.disconnect_callbacks.append(
|
||||
entry_data.disconnect_callbacks.add(
|
||||
await async_connect_scanner(
|
||||
hass, entry, cli, entry_data, self.domain_data.bluetooth_cache
|
||||
)
|
||||
@@ -459,7 +459,7 @@ class ESPHomeManager:
|
||||
await cli.subscribe_home_assistant_states(self.async_on_state_subscription)
|
||||
|
||||
if device_info.voice_assistant_version:
|
||||
entry_data.disconnect_callbacks.append(
|
||||
entry_data.disconnect_callbacks.add(
|
||||
await cli.subscribe_voice_assistant(
|
||||
self._handle_pipeline_start,
|
||||
self._handle_pipeline_stop,
|
||||
@@ -487,10 +487,7 @@ class ESPHomeManager:
|
||||
host,
|
||||
expected_disconnect,
|
||||
)
|
||||
for disconnect_cb in entry_data.disconnect_callbacks:
|
||||
disconnect_cb()
|
||||
entry_data.disconnect_callbacks = []
|
||||
entry_data.available = False
|
||||
entry_data.async_on_disconnect()
|
||||
entry_data.expected_disconnect = expected_disconnect
|
||||
# Mark state as stale so that we will always dispatch
|
||||
# the next state update of that type when the device reconnects
|
||||
@@ -596,6 +593,10 @@ def _async_setup_device_registry(
|
||||
model = project_name[1]
|
||||
hw_version = device_info.project_version
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
suggested_area = device_info.suggested_area
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
@@ -606,6 +607,7 @@ def _async_setup_device_registry(
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
hw_version=hw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
return device_entry.id
|
||||
|
||||
@@ -750,10 +752,7 @@ async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEn
|
||||
"""Cleanup the esphome client if it exists."""
|
||||
domain_data = DomainData.get(hass)
|
||||
data = domain_data.pop_entry_data(entry)
|
||||
data.available = False
|
||||
for disconnect_cb in data.disconnect_callbacks:
|
||||
disconnect_cb()
|
||||
data.disconnect_callbacks = []
|
||||
data.async_on_disconnect()
|
||||
for cleanup_callback in data.cleanup_callbacks:
|
||||
cleanup_callback()
|
||||
await data.async_cleanup()
|
||||
|
@@ -16,8 +16,8 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
||||
"requirements": [
|
||||
"async-interrupt==1.1.1",
|
||||
"aioesphomeapi==18.1.0",
|
||||
"bluetooth-data-tools==1.13.0",
|
||||
"aioesphomeapi==18.2.4",
|
||||
"bluetooth-data-tools==1.14.0",
|
||||
"esphome-dashboard-api==1.2.3"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
|
@@ -487,6 +487,18 @@ class EvoBroker:
|
||||
)
|
||||
self.temps = None # these are now stale, will fall back to v2 temps
|
||||
|
||||
except KeyError as err:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Unable to obtain high-precision temperatures. "
|
||||
"It appears the JSON schema is not as expected, "
|
||||
"so the high-precision feature will be disabled until next restart."
|
||||
"Message is: %s"
|
||||
),
|
||||
err,
|
||||
)
|
||||
self.client_v1 = self.temps = None
|
||||
|
||||
else:
|
||||
if (
|
||||
str(self.client_v1.location_id)
|
||||
@@ -495,7 +507,7 @@ class EvoBroker:
|
||||
_LOGGER.warning(
|
||||
"The v2 API's configured location doesn't match "
|
||||
"the v1 API's default location (there is more than one location), "
|
||||
"so the high-precision feature will be disabled"
|
||||
"so the high-precision feature will be disabled until next restart"
|
||||
)
|
||||
self.client_v1 = self.temps = None
|
||||
else:
|
||||
|
@@ -3,14 +3,24 @@ from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_automation import toggle_entity
|
||||
from homeassistant.components.device_automation import (
|
||||
async_validate_entity_schema,
|
||||
toggle_entity,
|
||||
)
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN})
|
||||
_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN})
|
||||
|
||||
|
||||
async def async_validate_action_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return async_validate_entity_schema(hass, config, _ACTION_SCHEMA)
|
||||
|
||||
|
||||
async def async_get_actions(
|
||||
|
@@ -69,7 +69,7 @@ class FitbitApi(ABC):
|
||||
profile = response["user"]
|
||||
self._profile = FitbitProfile(
|
||||
encoded_id=profile["encodedId"],
|
||||
full_name=profile["fullName"],
|
||||
display_name=profile["displayName"],
|
||||
locale=profile.get("locale"),
|
||||
)
|
||||
return self._profile
|
||||
|
@@ -59,13 +59,16 @@ class FitbitOAuth2Implementation(AuthImplementation):
|
||||
resp = await session.post(self.token_url, data=data, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
error_body = await resp.text()
|
||||
_LOGGER.debug("Client response error body: %s", error_body)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
error_body = await resp.text() if not session.closed else ""
|
||||
_LOGGER.debug(
|
||||
"Client response error status=%s, body=%s", err.status, error_body
|
||||
)
|
||||
if err.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise FitbitAuthException from err
|
||||
raise FitbitApiException from err
|
||||
raise FitbitAuthException(f"Unauthorized error: {err}") from err
|
||||
raise FitbitApiException(f"Server error response: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise FitbitApiException from err
|
||||
raise FitbitApiException(f"Client connection error: {err}") from err
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
@property
|
||||
|
@@ -53,6 +53,21 @@ class OAuth2FlowHandler(
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_creation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Create config entry from external data with Fitbit specific error handling."""
|
||||
try:
|
||||
return await super().async_step_creation()
|
||||
except FitbitAuthException as err:
|
||||
_LOGGER.error(
|
||||
"Failed to authenticate when creating Fitbit credentials: %s", err
|
||||
)
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
except FitbitApiException as err:
|
||||
_LOGGER.error("Failed to create Fitbit credentials: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
|
||||
@@ -75,7 +90,7 @@ class OAuth2FlowHandler(
|
||||
|
||||
await self.async_set_unique_id(profile.encoded_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=profile.full_name, data=data)
|
||||
return self.async_create_entry(title=profile.display_name, data=data)
|
||||
|
||||
async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Handle import from YAML."""
|
||||
|
@@ -14,8 +14,8 @@ class FitbitProfile:
|
||||
encoded_id: str
|
||||
"""The ID representing the Fitbit user."""
|
||||
|
||||
full_name: str
|
||||
"""The first name value specified in the user's account settings."""
|
||||
display_name: str
|
||||
"""The name shown when the user's friends look at their Fitbit profile."""
|
||||
|
||||
locale: str | None
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
@@ -8,6 +8,8 @@ import logging
|
||||
import os
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
@@ -132,6 +134,17 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume:
|
||||
return UnitOfVolume.MILLILITERS
|
||||
|
||||
|
||||
def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]:
|
||||
"""Value function that will parse the specified field if present."""
|
||||
|
||||
def convert(result: dict[str, Any]) -> int | None:
|
||||
if (value := result["value"].get(field)) is not None:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Fitbit sensor entity."""
|
||||
@@ -204,7 +217,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||
name="Resting Heart Rate",
|
||||
native_unit_of_measurement="bpm",
|
||||
icon="mdi:heart-pulse",
|
||||
value_fn=lambda result: int(result["value"]["restingHeartRate"]),
|
||||
value_fn=_int_value_or_none("restingHeartRate"),
|
||||
scope=FitbitScope.HEART_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
@@ -567,34 +580,54 @@ async def async_setup_platform(
|
||||
|
||||
if config_file is not None:
|
||||
_LOGGER.debug("Importing existing fitbit.conf application credentials")
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
|
||||
),
|
||||
|
||||
# Refresh the token before importing to ensure it is working and not
|
||||
# expired on first initialization.
|
||||
authd_client = Fitbit(
|
||||
config_file[CONF_CLIENT_ID],
|
||||
config_file[CONF_CLIENT_SECRET],
|
||||
access_token=config_file[ATTR_ACCESS_TOKEN],
|
||||
refresh_token=config_file[ATTR_REFRESH_TOKEN],
|
||||
expires_at=config_file[ATTR_LAST_SAVED_AT],
|
||||
refresh_cb=lambda x: None,
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_TOKEN: {
|
||||
ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN],
|
||||
ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN],
|
||||
"expires_at": config_file[ATTR_LAST_SAVED_AT],
|
||||
},
|
||||
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
|
||||
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
|
||||
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
|
||||
},
|
||||
)
|
||||
translation_key = "deprecated_yaml_import"
|
||||
if (
|
||||
result.get("type") == FlowResultType.ABORT
|
||||
and result.get("reason") == "cannot_connect"
|
||||
):
|
||||
try:
|
||||
updated_token = await hass.async_add_executor_job(
|
||||
authd_client.client.refresh_token
|
||||
)
|
||||
except OAuth2Error as err:
|
||||
_LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err)
|
||||
translation_key = "deprecated_yaml_import_issue_cannot_connect"
|
||||
else:
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
|
||||
),
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_TOKEN: {
|
||||
ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN],
|
||||
ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN],
|
||||
"expires_at": updated_token["expires_at"],
|
||||
"scope": " ".join(updated_token.get("scope", [])),
|
||||
},
|
||||
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
|
||||
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
|
||||
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
|
||||
},
|
||||
)
|
||||
translation_key = "deprecated_yaml_import"
|
||||
if (
|
||||
result.get("type") == FlowResultType.ABORT
|
||||
and result.get("reason") == "cannot_connect"
|
||||
):
|
||||
translation_key = "deprecated_yaml_import_issue_cannot_connect"
|
||||
else:
|
||||
translation_key = "deprecated_yaml_no_import"
|
||||
|
||||
|
@@ -16,9 +16,10 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"wrong_account": "The user credentials provided do not match this Fitbit account."
|
||||
|
@@ -139,9 +139,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return self._device_information["fwVersion"]
|
||||
|
||||
@property
|
||||
def serial_number(self) -> str:
|
||||
def serial_number(self) -> str | None:
|
||||
"""Return the serial number for the device."""
|
||||
return self._device_information["serialNumber"]
|
||||
return self._device_information.get("serialNumber")
|
||||
|
||||
@property
|
||||
def pending_info_alerts_count(self) -> int:
|
||||
|
@@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import (
|
||||
@@ -204,7 +204,7 @@ class FroniusSolarNet:
|
||||
|
||||
# Only for re-scans. Initial setup adds entities through sensor.async_setup_entry
|
||||
if self.config_entry.state == ConfigEntryState.LOADED:
|
||||
dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator)
|
||||
async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator)
|
||||
|
||||
_LOGGER.debug(
|
||||
"New inverter added (UID: %s)",
|
||||
|
@@ -661,7 +661,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
|
||||
if new_value is None:
|
||||
return self.entity_description.default_value
|
||||
if self.entity_description.invalid_when_falsy and not new_value:
|
||||
raise ValueError(f"Ignoring zero value for {self.entity_id}.")
|
||||
return None
|
||||
if isinstance(new_value, float):
|
||||
return round(new_value, 4)
|
||||
return new_value
|
||||
@@ -671,10 +671,9 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
|
||||
"""Handle updated data from the coordinator."""
|
||||
try:
|
||||
self._attr_native_value = self._get_entity_value()
|
||||
except (KeyError, ValueError):
|
||||
except KeyError:
|
||||
# sets state to `None` if no default_value is defined in entity description
|
||||
# KeyError: raised when omitted in response - eg. at night when no production
|
||||
# ValueError: raised when invalid zero value received
|
||||
self._attr_native_value = self.entity_description.default_value
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
@@ -388,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Can be removed in 2023
|
||||
hass.http.register_redirect("/config/server_control", "/developer-tools/yaml")
|
||||
|
||||
# Shopping list panel was replaced by todo panel in 2023.11
|
||||
hass.http.register_redirect("/shopping-list", "/todo")
|
||||
|
||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20231025.1"]
|
||||
"requirements": ["home-assistant-frontend==20231030.2"]
|
||||
}
|
||||
|
@@ -88,7 +88,6 @@ DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.measurement_timestamp.uuid,
|
||||
translation_key="sensor_measurement_timestamp",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.measurement_timestamp,
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/geniushub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["geniushubclient"],
|
||||
"requirements": ["geniushub-client==0.7.0"]
|
||||
"requirements": ["geniushub-client==0.7.1"]
|
||||
}
|
||||
|
@@ -521,8 +521,13 @@ class GoogleCalendarEntity(
|
||||
def _get_calendar_event(event: Event) -> CalendarEvent:
|
||||
"""Return a CalendarEvent from an API event."""
|
||||
rrule: str | None = None
|
||||
if len(event.recurrence) == 1:
|
||||
rrule = event.recurrence[0].lstrip(RRULE_PREFIX)
|
||||
# Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored
|
||||
if (
|
||||
len(event.recurrence) == 1
|
||||
and (raw_rule := event.recurrence[0])
|
||||
and raw_rule.startswith(RRULE_PREFIX)
|
||||
):
|
||||
rrule = raw_rule.removeprefix(RRULE_PREFIX)
|
||||
return CalendarEvent(
|
||||
uid=event.ical_uuid,
|
||||
recurrence_id=event.id if event.recurring_event_id else None,
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==5.0.0", "oauth2client==4.1.3"]
|
||||
"requirements": ["gcal-sync==6.0.1", "oauth2client==4.1.3"]
|
||||
}
|
||||
|
@@ -1,12 +1,9 @@
|
||||
"""Support for Google Mail."""
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -35,16 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
auth = AsyncConfigEntryAuth(session)
|
||||
try:
|
||||
await auth.check_and_refresh_token()
|
||||
except ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
await auth.check_and_refresh_token()
|
||||
hass.data[DOMAIN][entry.entry_id] = auth
|
||||
|
||||
hass.async_create_task(
|
||||
|
@@ -1,9 +1,16 @@
|
||||
"""API for Google Mail bound to Home Assistant OAuth."""
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import Resource, build
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
|
||||
@@ -24,14 +31,30 @@ class AsyncConfigEntryAuth:
|
||||
|
||||
async def check_and_refresh_token(self) -> str:
|
||||
"""Check the token."""
|
||||
await self.oauth_session.async_ensure_token_valid()
|
||||
try:
|
||||
await self.oauth_session.async_ensure_token_valid()
|
||||
except (RefreshError, ClientResponseError, ClientError) as ex:
|
||||
if (
|
||||
self.oauth_session.config_entry.state
|
||||
is ConfigEntryState.SETUP_IN_PROGRESS
|
||||
):
|
||||
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
if (
|
||||
isinstance(ex, RefreshError)
|
||||
or hasattr(ex, "status")
|
||||
and ex.status == 400
|
||||
):
|
||||
self.oauth_session.config_entry.async_start_reauth(
|
||||
self.oauth_session.hass
|
||||
)
|
||||
raise HomeAssistantError(ex) from ex
|
||||
return self.access_token
|
||||
|
||||
async def get_resource(self) -> Resource:
|
||||
"""Get current resource."""
|
||||
try:
|
||||
credentials = Credentials(await self.check_and_refresh_token())
|
||||
except RefreshError as ex:
|
||||
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
|
||||
raise ex
|
||||
credentials = Credentials(await self.check_and_refresh_token())
|
||||
return build("gmail", "v1", credentials=credentials)
|
||||
|
@@ -2,6 +2,13 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
from googleapiclient.http import HttpRequest
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN, OAUTH2_SCOPES
|
||||
@@ -28,3 +35,24 @@ class OAuth2FlowHandler(
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
try:
|
||||
resource = build(
|
||||
"tasks",
|
||||
"v1",
|
||||
credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
|
||||
)
|
||||
cmd: HttpRequest = resource.tasklists().list()
|
||||
await self.hass.async_add_executor_job(cmd.execute)
|
||||
except HttpError as ex:
|
||||
error = ex.reason
|
||||
return self.async_abort(
|
||||
reason="access_not_configured",
|
||||
description_placeholders={"message": error},
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self.logger.exception("Unknown error occurred: %s", ex)
|
||||
return self.async_abort(reason="unknown")
|
||||
return self.async_create_entry(title=self.flow_impl.name, data=data)
|
||||
|
@@ -15,7 +15,9 @@
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"access_not_configured": "Unable to access the Google API:\n\n{message}",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==4.9.0",
|
||||
"HAP-python==4.9.1",
|
||||
"fnv-hash-fast==0.5.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
|
@@ -884,7 +884,9 @@ class HKDevice:
|
||||
self._config_changed_callbacks.add(callback_)
|
||||
return partial(self._remove_config_changed_callback, callback_)
|
||||
|
||||
async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
async def get_characteristics(
|
||||
self, *args: Any, **kwargs: Any
|
||||
) -> dict[tuple[int, int], dict[str, Any]]:
|
||||
"""Read latest state from homekit accessory."""
|
||||
return await self.pairing.get_characteristics(*args, **kwargs)
|
||||
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.0.8"],
|
||||
"requirements": ["aiohomekit==3.0.9"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ async def async_setup_entry(
|
||||
class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity):
|
||||
"""Representation of a identify button."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_device_class = ButtonDeviceClass.IDENTIFY
|
||||
|
||||
def __init__(
|
||||
|
@@ -62,7 +62,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
return self.async_create_entry(
|
||||
title=f"{device_info.product_name} ({device_info.serial})",
|
||||
title=f"{device_info.product_name}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
@@ -121,14 +121,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {"base": ex.error_code}
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{self.discovery.product_name} ({self.discovery.serial})",
|
||||
title=self.discovery.product_name,
|
||||
data={CONF_IP_ADDRESS: self.discovery.ip},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
self.context["title_placeholders"] = {
|
||||
"name": f"{self.discovery.product_name} ({self.discovery.serial})"
|
||||
}
|
||||
|
||||
# We won't be adding mac/serial to the title for devices
|
||||
# that users generally don't have multiple of.
|
||||
name = self.discovery.product_name
|
||||
if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]:
|
||||
name = f"{name} ({self.discovery.serial})"
|
||||
self.context["title_placeholders"] = {"name": name}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
|
@@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
meter_data = {
|
||||
"device": asdict(coordinator.data.device),
|
||||
"data": asdict(coordinator.data.data),
|
||||
"state": asdict(coordinator.data.state)
|
||||
if coordinator.data.state is not None
|
||||
else None,
|
||||
"system": asdict(coordinator.data.system)
|
||||
if coordinator.data.system is not None
|
||||
else None,
|
||||
}
|
||||
state: dict[str, Any] | None = None
|
||||
if coordinator.data.state:
|
||||
state = asdict(coordinator.data.state)
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": async_redact_data(meter_data, TO_REDACT),
|
||||
}
|
||||
system: dict[str, Any] | None = None
|
||||
if coordinator.data.system:
|
||||
system = asdict(coordinator.data.system)
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
"entry": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"device": asdict(coordinator.data.device),
|
||||
"data": asdict(coordinator.data.data),
|
||||
"state": state,
|
||||
"system": system,
|
||||
},
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
|
@@ -18,17 +18,13 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
|
||||
"""Initialize the HomeWizard entity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=coordinator.entry.title,
|
||||
manufacturer="HomeWizard",
|
||||
sw_version=coordinator.data.device.firmware_version,
|
||||
model=coordinator.data.device.product_type,
|
||||
)
|
||||
|
||||
if coordinator.data.device.serial is not None:
|
||||
if (serial_number := coordinator.data.device.serial) is not None:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, coordinator.data.device.serial)
|
||||
}
|
||||
|
||||
self._attr_device_info[ATTR_IDENTIFIERS] = {
|
||||
(DOMAIN, coordinator.data.device.serial)
|
||||
(CONNECTION_NETWORK_MAC, serial_number)
|
||||
}
|
||||
self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)}
|
||||
|
@@ -47,13 +47,17 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
|
||||
await self.coordinator.api.state_set(brightness=int(value * (255 / 100)))
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.state is not None
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
if (
|
||||
self.coordinator.data.state is None
|
||||
or self.coordinator.data.state.brightness is None
|
||||
not self.coordinator.data.state
|
||||
or (brightness := self.coordinator.data.state.brightness) is None
|
||||
):
|
||||
return None
|
||||
brightness: float = self.coordinator.data.state.brightness
|
||||
return round(brightness * (100 / 255))
|
||||
|
@@ -38,6 +38,7 @@ from .const import (
|
||||
CONF_COOL_AWAY_TEMPERATURE,
|
||||
CONF_HEAT_AWAY_TEMPERATURE,
|
||||
DOMAIN,
|
||||
RETRY,
|
||||
)
|
||||
|
||||
ATTR_FAN_ACTION = "fan_action"
|
||||
@@ -155,6 +156,7 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
self._cool_away_temp = cool_away_temp
|
||||
self._heat_away_temp = heat_away_temp
|
||||
self._away = False
|
||||
self._retry = 0
|
||||
|
||||
self._attr_unique_id = device.deviceid
|
||||
|
||||
@@ -351,6 +353,11 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if mode == "heat":
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
|
||||
except UnexpectedResponse as err:
|
||||
raise HomeAssistantError(
|
||||
"Honeywell set temperature failed: Invalid Response"
|
||||
) from err
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
raise ValueError(
|
||||
@@ -367,6 +374,11 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW):
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
|
||||
except UnexpectedResponse as err:
|
||||
raise HomeAssistantError(
|
||||
"Honeywell set temperature failed: Invalid Response"
|
||||
) from err
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
raise ValueError(
|
||||
@@ -483,21 +495,28 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
try:
|
||||
await self._device.refresh()
|
||||
self._attr_available = True
|
||||
self._retry = 0
|
||||
|
||||
except UnauthorizedError:
|
||||
try:
|
||||
await self._data.client.login()
|
||||
await self._device.refresh()
|
||||
self._attr_available = True
|
||||
self._retry = 0
|
||||
|
||||
except (
|
||||
SomeComfortError,
|
||||
ClientConnectionError,
|
||||
asyncio.TimeoutError,
|
||||
):
|
||||
self._attr_available = False
|
||||
self._retry += 1
|
||||
if self._retry > RETRY:
|
||||
self._attr_available = False
|
||||
|
||||
except (ClientConnectionError, asyncio.TimeoutError):
|
||||
self._attr_available = False
|
||||
self._retry += 1
|
||||
if self._retry > RETRY:
|
||||
self._attr_available = False
|
||||
|
||||
except UnexpectedResponse:
|
||||
pass
|
||||
|
@@ -10,3 +10,4 @@ DEFAULT_HEAT_AWAY_TEMPERATURE = 61
|
||||
CONF_DEV_ID = "thermostat"
|
||||
CONF_LOC_ID = "location"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
RETRY = 3
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["somecomfort"],
|
||||
"requirements": ["AIOSomecomfort==0.0.17"]
|
||||
"requirements": ["AIOSomecomfort==0.0.22"]
|
||||
}
|
||||
|
@@ -242,5 +242,6 @@ class IpBanManager:
|
||||
|
||||
async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None:
|
||||
"""Add a new IP address to the banned list."""
|
||||
new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr)
|
||||
await self.hass.async_add_executor_job(self._add_ban, new_ban)
|
||||
if remote_addr not in self.ip_bans_lookup:
|
||||
new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr)
|
||||
await self.hass.async_add_executor_job(self._add_ban, new_ban)
|
||||
|
@@ -146,7 +146,7 @@ SENSORS_INFO = [
|
||||
translation_key="energy_today",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
key=SOURCE_TYPE_ELECTRICITY,
|
||||
sensor_type=SENSOR_TYPE_THIS_DAY,
|
||||
precision=1,
|
||||
@@ -156,7 +156,7 @@ SENSORS_INFO = [
|
||||
translation_key="energy_week",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
key=SOURCE_TYPE_ELECTRICITY,
|
||||
sensor_type=SENSOR_TYPE_THIS_WEEK,
|
||||
precision=1,
|
||||
@@ -166,7 +166,7 @@ SENSORS_INFO = [
|
||||
translation_key="energy_month",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
key=SOURCE_TYPE_ELECTRICITY,
|
||||
sensor_type=SENSOR_TYPE_THIS_MONTH,
|
||||
precision=1,
|
||||
@@ -176,7 +176,7 @@ SENSORS_INFO = [
|
||||
translation_key="energy_year",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
key=SOURCE_TYPE_ELECTRICITY,
|
||||
sensor_type=SENSOR_TYPE_THIS_YEAR,
|
||||
precision=1,
|
||||
@@ -197,7 +197,7 @@ SENSORS_INFO = [
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
key=SOURCE_TYPE_GAS,
|
||||
sensor_type=SENSOR_TYPE_THIS_DAY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
icon="mdi:counter",
|
||||
precision=1,
|
||||
),
|
||||
@@ -207,7 +207,7 @@ SENSORS_INFO = [
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
key=SOURCE_TYPE_GAS,
|
||||
sensor_type=SENSOR_TYPE_THIS_WEEK,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
icon="mdi:counter",
|
||||
precision=1,
|
||||
),
|
||||
@@ -217,7 +217,7 @@ SENSORS_INFO = [
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
key=SOURCE_TYPE_GAS,
|
||||
sensor_type=SENSOR_TYPE_THIS_MONTH,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
icon="mdi:counter",
|
||||
precision=1,
|
||||
),
|
||||
@@ -227,7 +227,7 @@ SENSORS_INFO = [
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
key=SOURCE_TYPE_GAS,
|
||||
sensor_type=SENSOR_TYPE_THIS_YEAR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
icon="mdi:counter",
|
||||
precision=1,
|
||||
),
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
|
||||
from pydrawise import legacy
|
||||
from requests.exceptions import ConnectTimeout, HTTPError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -13,11 +12,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
@@ -53,24 +51,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up Hydrawise from a config entry."""
|
||||
access_token = config_entry.data[CONF_API_KEY]
|
||||
try:
|
||||
hydrawise = await hass.async_add_executor_job(
|
||||
legacy.LegacyHydrawise, access_token
|
||||
)
|
||||
except (ConnectTimeout, HTTPError) as ex:
|
||||
LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex))
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to Hydrawise cloud service: {ex}"
|
||||
) from ex
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
config_entry.entry_id
|
||||
] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
|
||||
if not hydrawise.controller_info or not hydrawise.controller_status:
|
||||
raise ConfigEntryNotReady("Hydrawise data not loaded")
|
||||
|
||||
# NOTE: We don't need to call async_config_entry_first_refresh() because
|
||||
# data is fetched when the Hydrawiser object is instantiated.
|
||||
hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False)
|
||||
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
@@ -12,12 +12,12 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
@@ -95,13 +95,10 @@ async def async_setup_entry(
|
||||
class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity):
|
||||
"""A sensor implementation for Hydrawise device."""
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Get the latest data and updates the state."""
|
||||
LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name)
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
if self.entity_description.key == "status":
|
||||
self._attr_is_on = self.coordinator.last_update_success
|
||||
elif self.entity_description.key == "is_watering":
|
||||
relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]]
|
||||
self._attr_is_on = relay_data["timestr"] == "Now"
|
||||
super()._handle_coordinator_update()
|
||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -36,3 +37,14 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
|
||||
name=data["name"],
|
||||
manufacturer=MANUFACTURER,
|
||||
)
|
||||
self._update_attrs()
|
||||
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
return # pragma: no cover
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Get the latest data and updates the state."""
|
||||
self._update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
@@ -11,13 +11,13 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
@@ -82,10 +82,8 @@ async def async_setup_entry(
|
||||
class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
"""A sensor implementation for Hydrawise device."""
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Get the latest data and updates the states."""
|
||||
LOGGER.debug("Updating Hydrawise sensor: %s", self.name)
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]]
|
||||
if self.entity_description.key == "watering_time":
|
||||
if relay_data["timestr"] == "Now":
|
||||
@@ -94,8 +92,6 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
self._attr_native_value = 0
|
||||
else: # _sensor_type == 'next_cycle'
|
||||
next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS)
|
||||
LOGGER.debug("New cycle time: %s", next_cycle)
|
||||
self._attr_native_value = dt_util.utc_from_timestamp(
|
||||
dt_util.as_timestamp(dt_util.now()) + next_cycle
|
||||
)
|
||||
super()._handle_coordinator_update()
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
@@ -23,7 +23,6 @@ from .const import (
|
||||
CONF_WATERING_TIME,
|
||||
DEFAULT_WATERING_TIME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .entity import HydrawiseEntity
|
||||
@@ -124,14 +123,11 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):
|
||||
elif self.entity_description.key == "auto_watering":
|
||||
self.coordinator.api.suspend_zone(365, zone_number)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update device state."""
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
zone_number = self.data["relay"]
|
||||
LOGGER.debug("Updating Hydrawise switch: %s", self.name)
|
||||
timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"]
|
||||
if self.entity_description.key == "manual_watering":
|
||||
self._attr_is_on = timestr == "Now"
|
||||
elif self.entity_description.key == "auto_watering":
|
||||
self._attr_is_on = timestr not in {"", "Now"}
|
||||
super()._handle_coordinator_update()
|
||||
|
@@ -6,6 +6,7 @@ from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
import email
|
||||
from email.header import decode_header, make_header
|
||||
from email.message import Message
|
||||
from email.utils import parseaddr, parsedate_to_datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -96,8 +97,9 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL:
|
||||
class ImapMessage:
|
||||
"""Class to parse an RFC822 email message."""
|
||||
|
||||
def __init__(self, raw_message: bytes) -> None:
|
||||
def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None:
|
||||
"""Initialize IMAP message."""
|
||||
self._charset = charset
|
||||
self.email_message = email.message_from_bytes(raw_message)
|
||||
|
||||
@property
|
||||
@@ -157,18 +159,30 @@ class ImapMessage:
|
||||
message_html: str | None = None
|
||||
message_untyped_text: str | None = None
|
||||
|
||||
def _decode_payload(part: Message) -> str:
|
||||
"""Try to decode text payloads.
|
||||
|
||||
Common text encodings are quoted-printable or base64.
|
||||
Falls back to the raw content part if decoding fails.
|
||||
"""
|
||||
try:
|
||||
return str(part.get_payload(decode=True).decode(self._charset))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return str(part.get_payload())
|
||||
|
||||
part: Message
|
||||
for part in self.email_message.walk():
|
||||
if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN:
|
||||
if message_text is None:
|
||||
message_text = part.get_payload()
|
||||
message_text = _decode_payload(part)
|
||||
elif part.get_content_type() == "text/html":
|
||||
if message_html is None:
|
||||
message_html = part.get_payload()
|
||||
message_html = _decode_payload(part)
|
||||
elif (
|
||||
part.get_content_type().startswith("text")
|
||||
and message_untyped_text is None
|
||||
):
|
||||
message_untyped_text = part.get_payload()
|
||||
message_untyped_text = str(part.get_payload())
|
||||
|
||||
if message_text is not None:
|
||||
return message_text
|
||||
@@ -223,7 +237,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||
"""Send a event for the last message if the last message was changed."""
|
||||
response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]")
|
||||
if response.result == "OK":
|
||||
message = ImapMessage(response.lines[1])
|
||||
message = ImapMessage(
|
||||
response.lines[1], charset=self.config_entry.data[CONF_CHARSET]
|
||||
)
|
||||
# Set `initial` to `False` if the last message is triggered again
|
||||
initial: bool = True
|
||||
if (message_id := message.message_id) == self._last_message_id:
|
||||
|
@@ -78,7 +78,7 @@ class IslamicPrayerTimeSensor(
|
||||
"""Initialize the Islamic prayer time sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = description.key
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=NAME,
|
||||
|
@@ -122,12 +122,12 @@ class KNXExposeSensor:
|
||||
"""Extract value from state."""
|
||||
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
value = self.expose_default
|
||||
elif self.expose_attribute is not None:
|
||||
_attr = state.attributes.get(self.expose_attribute)
|
||||
value = _attr if _attr is not None else self.expose_default
|
||||
else:
|
||||
value = (
|
||||
state.state
|
||||
if self.expose_attribute is None
|
||||
else state.attributes.get(self.expose_attribute, self.expose_default)
|
||||
)
|
||||
value = state.state
|
||||
|
||||
if self.expose_type == "binary":
|
||||
if value in (1, STATE_ON, "True"):
|
||||
return True
|
||||
|
@@ -12,7 +12,7 @@
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==2.11.2",
|
||||
"xknxproject==3.3.0",
|
||||
"xknxproject==3.4.0",
|
||||
"knx-frontend==2023.6.23.191712"
|
||||
]
|
||||
}
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bluetooth-data-tools==1.13.0", "ld2410-ble==0.1.1"]
|
||||
"requirements": ["bluetooth-data-tools==1.14.0", "ld2410-ble==0.1.1"]
|
||||
}
|
||||
|
@@ -32,5 +32,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.13.0", "led-ble==1.0.1"]
|
||||
"requirements": ["bluetooth-data-tools==1.14.0", "led-ble==1.0.1"]
|
||||
}
|
||||
|
@@ -149,31 +149,29 @@ async def _async_reproduce_state(
|
||||
service = SERVICE_TURN_ON
|
||||
for attr in ATTR_GROUP:
|
||||
# All attributes that are not colors
|
||||
if attr in state.attributes:
|
||||
service_data[attr] = state.attributes[attr]
|
||||
if (attr_state := state.attributes.get(attr)) is not None:
|
||||
service_data[attr] = attr_state
|
||||
|
||||
if (
|
||||
state.attributes.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN)
|
||||
!= ColorMode.UNKNOWN
|
||||
):
|
||||
color_mode = state.attributes[ATTR_COLOR_MODE]
|
||||
if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode):
|
||||
if color_mode_attr.state_attr not in state.attributes:
|
||||
if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode):
|
||||
if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None:
|
||||
_LOGGER.warning(
|
||||
"Color mode %s specified but attribute %s missing for: %s",
|
||||
color_mode,
|
||||
color_mode_attr.state_attr,
|
||||
cm_attr.state_attr,
|
||||
state.entity_id,
|
||||
)
|
||||
return
|
||||
service_data[color_mode_attr.parameter] = state.attributes[
|
||||
color_mode_attr.state_attr
|
||||
]
|
||||
service_data[cm_attr.parameter] = cm_attr_state
|
||||
else:
|
||||
# Fall back to Choosing the first color that is specified
|
||||
for color_attr in COLOR_GROUP:
|
||||
if color_attr in state.attributes:
|
||||
service_data[color_attr] = state.attributes[color_attr]
|
||||
if (color_attr_state := state.attributes.get(color_attr)) is not None:
|
||||
service_data[color_attr] = color_attr_state
|
||||
break
|
||||
|
||||
elif state.state == STATE_OFF:
|
||||
|
@@ -9,9 +9,9 @@ from typing import Any
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.event import Event
|
||||
from ical.exceptions import CalendarParseError
|
||||
from ical.store import EventStore, EventStoreError
|
||||
from ical.types import Range, Recur
|
||||
from pydantic import ValidationError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
@@ -178,8 +178,8 @@ def _parse_event(event: dict[str, Any]) -> Event:
|
||||
event[key] = dt_util.as_local(value).replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return Event.parse_obj(event)
|
||||
except ValidationError as err:
|
||||
return Event(**event)
|
||||
except CalendarParseError as err:
|
||||
_LOGGER.debug("Error parsing event input fields: %s (%s)", event, str(err))
|
||||
raise vol.Invalid("Error parsing event input fields") from err
|
||||
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==5.1.0"]
|
||||
"requirements": ["ical==6.1.0"]
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==5.1.0"]
|
||||
"requirements": ["ical==6.1.0"]
|
||||
}
|
||||
|
@@ -7,9 +7,9 @@ from typing import Any
|
||||
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.exceptions import CalendarParseError
|
||||
from ical.store import TodoStore
|
||||
from ical.todo import Todo, TodoStatus
|
||||
from pydantic import ValidationError
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
@@ -63,9 +63,11 @@ def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]:
|
||||
"""Convert TodoItem dataclass items to dictionary of attributes for ical consumption."""
|
||||
result: dict[str, str] = {}
|
||||
for name, value in obj:
|
||||
if value is None:
|
||||
continue
|
||||
if name == "status":
|
||||
result[name] = ICS_TODO_STATUS_MAP_INV[value]
|
||||
elif value is not None:
|
||||
else:
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
@@ -74,7 +76,7 @@ def _convert_item(item: TodoItem) -> Todo:
|
||||
"""Convert a HomeAssistant TodoItem to an ical Todo."""
|
||||
try:
|
||||
return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory))
|
||||
except ValidationError as err:
|
||||
except CalendarParseError as err:
|
||||
_LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err)
|
||||
raise HomeAssistantError("Error parsing todo input fields") from err
|
||||
|
||||
@@ -139,20 +141,28 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_move_todo_item(self, uid: str, pos: int) -> None:
|
||||
async def async_move_todo_item(
|
||||
self, uid: str, previous_uid: str | None = None
|
||||
) -> None:
|
||||
"""Re-order an item to the To-do list."""
|
||||
if uid == previous_uid:
|
||||
return
|
||||
todos = self._calendar.todos
|
||||
found_item: Todo | None = None
|
||||
for idx, itm in enumerate(todos):
|
||||
if itm.uid == uid:
|
||||
found_item = itm
|
||||
todos.pop(idx)
|
||||
break
|
||||
if found_item is None:
|
||||
item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)}
|
||||
if uid not in item_idx:
|
||||
raise HomeAssistantError(
|
||||
f"Item '{uid}' not found in todo list {self.entity_id}"
|
||||
"Item '{uid}' not found in todo list {self.entity_id}"
|
||||
)
|
||||
todos.insert(pos, found_item)
|
||||
if previous_uid and previous_uid not in item_idx:
|
||||
raise HomeAssistantError(
|
||||
"Item '{previous_uid}' not found in todo list {self.entity_id}"
|
||||
)
|
||||
dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0
|
||||
src_idx = item_idx[uid]
|
||||
src_item = todos.pop(src_idx)
|
||||
if dst_idx > src_idx:
|
||||
dst_idx -= 1
|
||||
todos.insert(dst_idx, src_item)
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
|
@@ -12,7 +12,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -95,7 +95,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check if already exists
|
||||
await self.async_set_unique_id(lock_data["bridge_mac_wifi"])
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host})
|
||||
self._abort_if_unique_id_configured({"bridge_ip": host})
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
|
@@ -60,6 +60,10 @@ async def websocket_lovelace_resources(
|
||||
"""Send Lovelace UI resources over WebSocket configuration."""
|
||||
resources = hass.data[DOMAIN]["resources"]
|
||||
|
||||
if hass.config.safe_mode:
|
||||
connection.send_result(msg["id"], [])
|
||||
return
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
|
@@ -27,7 +27,7 @@ def setup_platform(
|
||||
|
||||
data = hass.data[LUPUSEC_DOMAIN]
|
||||
|
||||
device_types = [CONST.TYPE_OPENING]
|
||||
device_types = CONST.TYPE_OPENING
|
||||
|
||||
devices = []
|
||||
for device in data.lupusec.get_devices(generic_type=device_types):
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lupusec",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["lupupy"],
|
||||
"requirements": ["lupupy==0.3.0"]
|
||||
"requirements": ["lupupy==0.3.1"]
|
||||
}
|
||||
|
@@ -28,9 +28,10 @@ def setup_platform(
|
||||
|
||||
data = hass.data[LUPUSEC_DOMAIN]
|
||||
|
||||
devices = []
|
||||
device_types = CONST.TYPE_SWITCH
|
||||
|
||||
for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH):
|
||||
devices = []
|
||||
for device in data.lupusec.get_devices(generic_type=device_types):
|
||||
devices.append(LupusecSwitch(data, device))
|
||||
|
||||
add_entities(devices)
|
||||
|
@@ -145,9 +145,7 @@ class MatterAdapter:
|
||||
get_clean_name(basic_info.nodeLabel)
|
||||
or get_clean_name(basic_info.productLabel)
|
||||
or get_clean_name(basic_info.productName)
|
||||
or device_type.__name__
|
||||
if device_type
|
||||
else None
|
||||
or (device_type.__name__ if device_type else None)
|
||||
)
|
||||
|
||||
# handle bridged devices
|
||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -110,7 +111,9 @@ class MatterEntity(Entity):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
for unsub in self._unsubscribes:
|
||||
unsub()
|
||||
with suppress(ValueError):
|
||||
# suppress ValueError to prevent race conditions
|
||||
unsub()
|
||||
|
||||
@callback
|
||||
def _on_matter_event(self, event: EventType, data: Any = None) -> None:
|
||||
|
@@ -1,12 +1,15 @@
|
||||
"""Matter lock."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag
|
||||
from typing import Any
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
|
||||
from homeassistant.components.lock import LockEntity, LockEntityDescription
|
||||
from homeassistant.components.lock import (
|
||||
LockEntity,
|
||||
LockEntityDescription,
|
||||
LockEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_CODE, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -17,6 +20,8 @@ from .entity import MatterEntity
|
||||
from .helpers import get_matter
|
||||
from .models import MatterDiscoverySchema
|
||||
|
||||
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -61,6 +66,14 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
|
||||
return bool(self.features & DoorLockFeature.kDoorPositionSensor)
|
||||
|
||||
@property
|
||||
def supports_unbolt(self) -> bool:
|
||||
"""Return True if the lock supports unbolt."""
|
||||
if self.features is None:
|
||||
return False
|
||||
|
||||
return bool(self.features & DoorLockFeature.kUnbolt)
|
||||
|
||||
async def send_device_command(
|
||||
self,
|
||||
command: clusters.ClusterCommand,
|
||||
@@ -92,6 +105,25 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
self._lock_option_default_code,
|
||||
)
|
||||
code_bytes = code.encode() if code else None
|
||||
if self.supports_unbolt:
|
||||
# if the lock reports it has separate unbolt support,
|
||||
# the unlock command should unbolt only on the unlock command
|
||||
# and unlatch on the HA 'open' command.
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes)
|
||||
)
|
||||
else:
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
|
||||
)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Open the door latch."""
|
||||
code: str = kwargs.get(
|
||||
ATTR_CODE,
|
||||
self._lock_option_default_code,
|
||||
)
|
||||
code_bytes = code.encode() if code else None
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
|
||||
)
|
||||
@@ -104,6 +136,8 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
self.features = int(
|
||||
self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap)
|
||||
)
|
||||
if self.supports_unbolt:
|
||||
self._attr_supported_features = LockEntityFeature.OPEN
|
||||
|
||||
lock_state = self.get_matter_attribute_value(
|
||||
clusters.DoorLock.Attributes.LockState
|
||||
@@ -144,26 +178,6 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
)
|
||||
|
||||
|
||||
class DoorLockFeature(IntFlag):
|
||||
"""Temp enum that represents the features of a door lock.
|
||||
|
||||
Should be replaced by the library provided one once that is released.
|
||||
"""
|
||||
|
||||
kPinCredential = 0x1 # noqa: N815
|
||||
kRfidCredential = 0x2 # noqa: N815
|
||||
kFingerCredentials = 0x4 # noqa: N815
|
||||
kLogging = 0x8 # noqa: N815
|
||||
kWeekDayAccessSchedules = 0x10 # noqa: N815
|
||||
kDoorPositionSensor = 0x20 # noqa: N815
|
||||
kFaceCredentials = 0x40 # noqa: N815
|
||||
kCredentialsOverTheAirAccess = 0x80 # noqa: N815
|
||||
kUser = 0x100 # noqa: N815
|
||||
kNotification = 0x200 # noqa: N815
|
||||
kYearDayAccessSchedules = 0x400 # noqa: N815
|
||||
kHolidaySchedules = 0x800 # noqa: N815
|
||||
|
||||
|
||||
DISCOVERY_SCHEMAS = [
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.LOCK,
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==4.0.0"]
|
||||
"requirements": ["python-matter-server==4.0.2"]
|
||||
}
|
||||
|
@@ -67,7 +67,15 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
entity_class=MatterSwitch,
|
||||
required_attributes=(clusters.OnOff.Attributes.OnOff,),
|
||||
# restrict device type to prevent discovery by the wrong platform
|
||||
device_type=(device_types.OnOffPlugInUnit,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SWITCH,
|
||||
entity_description=SwitchEntityDescription(
|
||||
key="MatterSwitch", device_class=SwitchDeviceClass.SWITCH, name=None
|
||||
),
|
||||
entity_class=MatterSwitch,
|
||||
required_attributes=(clusters.OnOff.Attributes.OnOff,),
|
||||
not_device_type=(
|
||||
device_types.ColorTemperatureLight,
|
||||
device_types.DimmableLight,
|
||||
@@ -76,7 +84,6 @@ DISCOVERY_SCHEMAS = [
|
||||
device_types.DoorLock,
|
||||
device_types.ColorDimmerSwitch,
|
||||
device_types.DimmerSwitch,
|
||||
device_types.OnOffLightSwitch,
|
||||
device_types.Thermostat,
|
||||
),
|
||||
),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user