forked from home-assistant/core
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe426a4fc | |||
| 2a384987e7 | |||
| 3ab1df4660 | |||
| b578a76cb9 | |||
| 243941f3fd | |||
| 018300858f | |||
| 5ec7cff3ff | |||
| d45a0cc41e | |||
| f490119fed | |||
| 23280268c8 | |||
| df2953403e | |||
| 1c06c6c1e6 | |||
| 6a73406e9f | |||
| 5ab7c8e9ba | |||
| dc7f0fb21c | |||
| 74c2639495 | |||
| b0714e32b1 | |||
| 6371cb4ebd | |||
| 987add50cb | |||
| 83db9a3335 | |||
| aade51248d | |||
| 228fa9f5a0 | |||
| 57c868e615 | |||
| 431f93e1d3 | |||
| 66d3891a37 | |||
| 2a641d1d19 | |||
| f8b5a97e72 | |||
| 609438d929 | |||
| 76cc26ad17 | |||
| 533efa2880 | |||
| 3bf3a1fd85 | |||
| 5306b32a48 | |||
| 4e82f134b2 | |||
| 0457a74428 | |||
| 8f3449d942 | |||
| 97929bd234 | |||
| a9d461a109 | |||
| f24549f7d1 | |||
| 0d62d80038 | |||
| 223d864b04 | |||
| 082d4079ef | |||
| 70b360b1f8 | |||
| 5488e9d5f3 | |||
| c8177f48ce | |||
| 930dc3615e | |||
| 1ecb7ab887 | |||
| 248ed3660f | |||
| 3a60466e7c | |||
| 04fda5638c | |||
| 252941ae26 | |||
| 2eacbef061 | |||
| 3f666396c9 | |||
| 7d20bb0532 | |||
| d94e969dc1 | |||
| 18842ef571 | |||
| f9ebbb936a | |||
| c757c9b99f | |||
| d88b2bf19c | |||
| 7ab2029071 | |||
| 59ec829106 | |||
| 345d356e9a | |||
| 42c09d8811 | |||
| f614df29bd | |||
| 815249eaeb | |||
| 1f878433ac | |||
| 797ea3ace4 | |||
| 3e8bea8fbd | |||
| b9db84ed57 | |||
| 823ec88c52 | |||
| 34ae83b4e2 | |||
| efb984aa83 | |||
| 7ca5bd341b | |||
| c8ed3fd302 | |||
| a8a3f012f6 | |||
| 11013bd780 | |||
| 2684a6e8ed | |||
| 63afb30f57 | |||
| f861137de4 | |||
| 08debee94f | |||
| c8981f78b7 | |||
| e4269ff8b2 | |||
| 6fb5c93182 | |||
| 6fa69022f4 | |||
| 4391640734 | |||
| c60c99bd74 | |||
| ac15f2cf9d | |||
| b9757235a7 | |||
| 9beb9f6fc0 | |||
| 9771147a1e | |||
| 0983f8aadf | |||
| 5a6423a944 | |||
| f9c7732090 | |||
| 55c87c733a | |||
| 6110700e18 | |||
| a8e1afb966 | |||
| d24e272d5e | |||
| bf5ecc30ed | |||
| 2a34d3a56f | |||
| 7124cedd7a | |||
| 087ede959d | |||
| 7b769b39c2 | |||
| 51ab5d1808 | |||
| 7832a7fd80 | |||
| c93c13d8bf | |||
| 42444872b9 | |||
| d3bd80b876 | |||
| a53d1e072d | |||
| 229d60e678 | |||
| dd004d62d4 | |||
| 8cbe303677 | |||
| 48edd54e62 | |||
| 8cb4e8452d | |||
| d4b7c00ed6 | |||
| 758e06b4b6 | |||
| 632231912e | |||
| e25cf0b338 | |||
| 32c5248ddb | |||
| 21baf50fc9 | |||
| 1f0073f450 | |||
| f14a84211f | |||
| 1ea0d0e47f | |||
| 28832e1c2e | |||
| 970fd9bdba | |||
| 3409dea28c | |||
| 06d22d8249 | |||
| a6e745b687 | |||
| f6c094b017 | |||
| 9f54e332ec | |||
| 3aca376374 | |||
| a5f209b219 | |||
| 0dbf0504ff | |||
| 95ce20638a | |||
| b9132e78b4 | |||
| e8f93d9c7f | |||
| 1efec8323a | |||
| 8c63a9ce5e | |||
| 9dff7ab6b9 | |||
| d0ddbb5f58 | |||
| f265c160d1 | |||
| a2d432dfd6 | |||
| c4bb225060 | |||
| 473490aee7 | |||
| f9493bc313 | |||
| 1cc85f77e3 | |||
| c2c57712d2 | |||
| 4684101a85 | |||
| 9b87f7f6f9 | |||
| 8965a1322c | |||
| dfe399e370 | |||
| 0ac0e9c0d5 | |||
| 941512641b | |||
| 599c23c1d7 | |||
| 882ad31a99 | |||
| 356953c8bc | |||
| 19a5c87da6 | |||
| d7e76fdf3a | |||
| 9b4f2df8f3 | |||
| 7046f5f19e | |||
| 3ddcc637da | |||
| 0a476baf16 | |||
| f3a96ce14b | |||
| 4fbbb7ba6d | |||
| 8eef55ed60 | |||
| 8f843b3046 | |||
| 13562d271e | |||
| 3cf63ec88e | |||
| 1f70941f6d | |||
| 81dde5cfdf | |||
| eccf61a546 | |||
| 9fac632dcd | |||
| e26149d0c3 | |||
| 8bafb56f04 | |||
| 94f92e7f8a | |||
| 5e3fb6ee9f | |||
| c36260dd17 | |||
| 0af69a1014 | |||
| 5f81f968ee | |||
| 9d88c95314 | |||
| 90a3689489 | |||
| 11bdddc1dc | |||
| a6bb7a0832 | |||
| 24b3d21815 | |||
| be138adb23 | |||
| 8d3ed60986 | |||
| 0465510ed7 | |||
| 96cdb29755 | |||
| 7e740b7c9d | |||
| d0a0285dd9 | |||
| 16fe7df19e | |||
| bf04f94e05 | |||
| 62635c2a96 | |||
| 43b1dd54d5 | |||
| 2dd8797f67 | |||
| 3323bf4ae9 | |||
| 85545e9740 | |||
| 1b7524a79e | |||
| 6f3b7d009d |
+5
-5
@@ -1,11 +1,11 @@
|
||||
image: homeassistant/{arch}-homeassistant
|
||||
shadow_repository: ghcr.io/home-assistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.07.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.07.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.07.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.07.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.07.0
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.10.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.10.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.10.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.10.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.10.0
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Adax",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"requirements": ["adax==0.2.0", "Adax-local==0.1.4"],
|
||||
"requirements": ["adax==0.2.0", "Adax-local==0.1.5"],
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Airthings BLE",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"requirements": ["airthings-ble==0.5.2"],
|
||||
"requirements": ["airthings-ble==0.5.3"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@vincegio"],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -7,13 +7,9 @@ from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual import CloudAPI, NodeSamba
|
||||
from pyairvisual.errors import (
|
||||
AirVisualError,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
NodeProError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.cloud_api import InvalidKeyError, KeyExpiredError, UnauthorizedError
|
||||
from pyairvisual.errors import AirVisualError
|
||||
from pyairvisual.node import NodeProError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
|
||||
@@ -6,14 +6,14 @@ from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual import CloudAPI, NodeSamba
|
||||
from pyairvisual.errors import (
|
||||
AirVisualError,
|
||||
from pyairvisual.cloud_api import (
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
NodeProError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.errors import AirVisualError
|
||||
from pyairvisual.node import NodeProError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "AirVisual",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airvisual",
|
||||
"requirements": ["pyairvisual==2022.07.0"],
|
||||
"requirements": ["pyairvisual==2022.11.1"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairvisual", "pysmb"],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "aladdin_connect",
|
||||
"name": "Aladdin Connect",
|
||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||
"requirements": ["AIOAladdinConnect==0.1.46"],
|
||||
"requirements": ["AIOAladdinConnect==0.1.47"],
|
||||
"codeowners": ["@mkmer"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aladdin_connect"],
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
import datetime
|
||||
import logging
|
||||
import platform
|
||||
from typing import TYPE_CHECKING, cast
|
||||
@@ -21,6 +22,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_ca
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, discovery_flow
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
@@ -33,6 +35,7 @@ from .const import (
|
||||
ADAPTER_ADDRESS,
|
||||
ADAPTER_HW_VERSION,
|
||||
ADAPTER_SW_VERSION,
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_PASSIVE,
|
||||
@@ -40,6 +43,7 @@ from .const import (
|
||||
DEFAULT_ADDRESS,
|
||||
DOMAIN,
|
||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
||||
SOURCE_LOCAL,
|
||||
AdapterDetails,
|
||||
)
|
||||
@@ -298,9 +302,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
await async_discover_adapters(hass, discovered_adapters)
|
||||
|
||||
discovery_debouncer = Debouncer(
|
||||
hass, _LOGGER, cooldown=5, immediate=False, function=_async_rediscover_adapters
|
||||
hass,
|
||||
_LOGGER,
|
||||
cooldown=BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
immediate=False,
|
||||
function=_async_rediscover_adapters,
|
||||
)
|
||||
|
||||
async def _async_call_debouncer(now: datetime.datetime) -> None:
|
||||
"""Call the debouncer at a later time."""
|
||||
await discovery_debouncer.async_call()
|
||||
|
||||
def _async_trigger_discovery() -> None:
|
||||
# There are so many bluetooth adapter models that
|
||||
# we check the bus whenever a usb device is plugged in
|
||||
@@ -310,6 +322,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# present.
|
||||
_LOGGER.debug("Triggering bluetooth usb discovery")
|
||||
hass.async_create_task(discovery_debouncer.async_call())
|
||||
# Because it can take 120s for the firmware loader
|
||||
# fallback to timeout we need to wait that plus
|
||||
# the debounce time to ensure we do not miss the
|
||||
# adapter becoming available to DBus since otherwise
|
||||
# we will never see the new adapter until
|
||||
# Home Assistant is restarted
|
||||
async_call_later(
|
||||
hass,
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
||||
_async_call_debouncer,
|
||||
)
|
||||
|
||||
cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery)
|
||||
hass.bus.async_listen_once(
|
||||
|
||||
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from bleak import BleakError
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .passive_update_processor import PassiveBluetoothProcessorCoordinator
|
||||
@@ -94,7 +94,7 @@ class ActiveBluetoothProcessorCoordinator(
|
||||
"""Return true if time to try and poll."""
|
||||
poll_age: float | None = None
|
||||
if self._last_poll:
|
||||
poll_age = time.monotonic() - self._last_poll
|
||||
poll_age = monotonic_time_coarse() - self._last_poll
|
||||
return self._needs_poll_method(service_info, poll_age)
|
||||
|
||||
async def _async_poll_data(
|
||||
@@ -124,7 +124,7 @@ class ActiveBluetoothProcessorCoordinator(
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
finally:
|
||||
self._last_poll = time.monotonic()
|
||||
self._last_poll = monotonic_time_coarse()
|
||||
|
||||
if not self.last_poll_successful:
|
||||
self.logger.debug("%s: Polling recovered")
|
||||
|
||||
@@ -59,6 +59,15 @@ SCANNER_WATCHDOG_TIMEOUT: Final = 90
|
||||
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30)
|
||||
|
||||
|
||||
# When the linux kernel is configured with
|
||||
# CONFIG_FW_LOADER_USER_HELPER_FALLBACK it
|
||||
# can take up to 120s before the USB device
|
||||
# is available if the firmware files
|
||||
# are not present
|
||||
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS = 120
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS = 5
|
||||
|
||||
|
||||
class AdapterDetails(TypedDict, total=False):
|
||||
"""Adapter details."""
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from dataclasses import replace
|
||||
from datetime import datetime, timedelta
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from bleak.backends.scanner import AdvertisementDataCallback
|
||||
@@ -22,6 +21,7 @@ from homeassistant.core import (
|
||||
)
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
from .advertisement_tracker import AdvertisementTracker
|
||||
from .const import (
|
||||
@@ -69,7 +69,7 @@ APPLE_START_BYTES_WANTED: Final = {
|
||||
APPLE_DEVICE_ID_START_BYTE,
|
||||
}
|
||||
|
||||
MONOTONIC_TIME: Final = time.monotonic
|
||||
MONOTONIC_TIME: Final = monotonic_time_coarse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -127,6 +127,7 @@ class BluetoothManager:
|
||||
self._non_connectable_scanners: list[BaseHaScanner] = []
|
||||
self._connectable_scanners: list[BaseHaScanner] = []
|
||||
self._adapters: dict[str, AdapterDetails] = {}
|
||||
self._sources: set[str] = set()
|
||||
|
||||
@property
|
||||
def supports_passive_scan(self) -> bool:
|
||||
@@ -379,6 +380,7 @@ class BluetoothManager:
|
||||
if (
|
||||
(old_service_info := all_history.get(address))
|
||||
and source != old_service_info.source
|
||||
and old_service_info.source in self._sources
|
||||
and self._prefer_previous_adv_from_different_source(
|
||||
old_service_info, service_info
|
||||
)
|
||||
@@ -398,6 +400,7 @@ class BluetoothManager:
|
||||
# the old connectable advertisement
|
||||
or (
|
||||
source != old_connectable_service_info.source
|
||||
and old_connectable_service_info.source in self._sources
|
||||
and self._prefer_previous_adv_from_different_source(
|
||||
old_connectable_service_info, service_info
|
||||
)
|
||||
@@ -597,8 +600,10 @@ class BluetoothManager:
|
||||
def _unregister_scanner() -> None:
|
||||
self._advertisement_tracker.async_remove_source(scanner.source)
|
||||
scanners.remove(scanner)
|
||||
self._sources.remove(scanner.source)
|
||||
|
||||
scanners.append(scanner)
|
||||
self._sources.add(scanner.source)
|
||||
return _unregister_scanner
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
"after_dependencies": ["hassio"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.19.0",
|
||||
"bleak-retry-connector==2.4.2",
|
||||
"bluetooth-adapters==0.6.0",
|
||||
"bleak==0.19.2",
|
||||
"bleak-retry-connector==2.8.5",
|
||||
"bluetooth-adapters==0.7.0",
|
||||
"bluetooth-auto-recovery==0.3.6",
|
||||
"dbus-fast==1.54.0"
|
||||
"dbus-fast==1.61.1"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -264,6 +264,7 @@ class HaBleakClientWrapper(BleakClient):
|
||||
self.__address = address_or_ble_device
|
||||
self.__disconnected_callback = disconnected_callback
|
||||
self.__timeout = timeout
|
||||
self.__ble_device: BLEDevice | None = None
|
||||
self._backend: BaseBleakClient | None = None # type: ignore[assignment]
|
||||
|
||||
@property
|
||||
@@ -283,14 +284,21 @@ class HaBleakClientWrapper(BleakClient):
|
||||
|
||||
async def connect(self, **kwargs: Any) -> bool:
|
||||
"""Connect to the specified GATT server."""
|
||||
if not self._backend:
|
||||
if (
|
||||
not self._backend
|
||||
or not self.__ble_device
|
||||
or not self._async_get_backend_for_ble_device(self.__ble_device)
|
||||
):
|
||||
assert MANAGER is not None
|
||||
wrapped_backend = (
|
||||
self._async_get_backend() or self._async_get_fallback_backend()
|
||||
)
|
||||
self._backend = wrapped_backend.client(
|
||||
self.__ble_device = (
|
||||
await freshen_ble_device(wrapped_backend.device)
|
||||
or wrapped_backend.device,
|
||||
or wrapped_backend.device
|
||||
)
|
||||
self._backend = wrapped_backend.client(
|
||||
self.__ble_device,
|
||||
disconnected_callback=self.__disconnected_callback,
|
||||
timeout=self.__timeout,
|
||||
hass=MANAGER.hass,
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
@@ -22,6 +21,7 @@ from dbus_fast import InvalidMessageError
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import (
|
||||
@@ -35,7 +35,7 @@ from .models import BaseHaScanner, BluetoothScanningMode, BluetoothServiceInfoBl
|
||||
from .util import adapter_human_name, async_reset_adapter
|
||||
|
||||
OriginalBleakScanner = bleak.BleakScanner
|
||||
MONOTONIC_TIME = time.monotonic
|
||||
MONOTONIC_TIME = monotonic_time_coarse
|
||||
|
||||
# or_patterns is a workaround for the fact that passive scanning
|
||||
# needs at least one matcher to be set. The below matcher
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import time
|
||||
|
||||
from bluetooth_auto_recovery import recover_adapter
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
from .const import (
|
||||
DEFAULT_ADAPTER_BY_PLATFORM,
|
||||
@@ -29,7 +29,7 @@ async def async_load_history_from_system() -> dict[str, BluetoothServiceInfoBlea
|
||||
|
||||
bluez_dbus = BlueZDBusObjects()
|
||||
await bluez_dbus.load()
|
||||
now = time.monotonic()
|
||||
now = monotonic_time_coarse()
|
||||
return {
|
||||
address: BluetoothServiceInfoBleak(
|
||||
name=history.advertisement_data.local_name
|
||||
|
||||
@@ -262,7 +262,11 @@ class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
self.config_entry.entry_id
|
||||
]
|
||||
|
||||
await coordinator.async_update_sources()
|
||||
try:
|
||||
await coordinator.async_update_sources()
|
||||
except BraviaTVError:
|
||||
return self.async_abort(reason="failed_update")
|
||||
|
||||
sources = coordinator.source_map.values()
|
||||
self.source_list = [item["title"] for item in sources]
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
"ignored_sources": "List of ignored sources"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"failed_update": "An error occurred while updating the list of sources.\n\n Ensure that your TV is turned on before trying to set it up."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"failed_update": "An error occurred while updating the list of sources.\n\n Ensure that your TV is turned on before trying to set it up."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
|
||||
@@ -89,6 +89,7 @@ T = TypeVar(
|
||||
class DeconzSensorDescriptionMixin(Generic[T]):
|
||||
"""Required values when describing secondary sensor attributes."""
|
||||
|
||||
supported_fn: Callable[[T], bool]
|
||||
update_key: str
|
||||
value_fn: Callable[[T], datetime | StateType]
|
||||
|
||||
@@ -105,6 +106,7 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi
|
||||
ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
DeconzSensorDescription[AirQuality](
|
||||
key="air_quality",
|
||||
supported_fn=lambda device: device.air_quality is not None,
|
||||
update_key="airquality",
|
||||
value_fn=lambda device: device.air_quality,
|
||||
instance_check=AirQuality,
|
||||
@@ -112,6 +114,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[AirQuality](
|
||||
key="air_quality_ppb",
|
||||
supported_fn=lambda device: device.air_quality_ppb is not None,
|
||||
update_key="airqualityppb",
|
||||
value_fn=lambda device: device.air_quality_ppb,
|
||||
instance_check=AirQuality,
|
||||
@@ -122,6 +125,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Consumption](
|
||||
key="consumption",
|
||||
supported_fn=lambda device: device.consumption is not None,
|
||||
update_key="consumption",
|
||||
value_fn=lambda device: device.scaled_consumption,
|
||||
instance_check=Consumption,
|
||||
@@ -131,6 +135,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Daylight](
|
||||
key="daylight_status",
|
||||
supported_fn=lambda device: True,
|
||||
update_key="status",
|
||||
value_fn=lambda device: DAYLIGHT_STATUS[device.daylight_status],
|
||||
instance_check=Daylight,
|
||||
@@ -139,12 +144,14 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[GenericStatus](
|
||||
key="status",
|
||||
supported_fn=lambda device: device.status is not None,
|
||||
update_key="status",
|
||||
value_fn=lambda device: device.status,
|
||||
instance_check=GenericStatus,
|
||||
),
|
||||
DeconzSensorDescription[Humidity](
|
||||
key="humidity",
|
||||
supported_fn=lambda device: device.humidity is not None,
|
||||
update_key="humidity",
|
||||
value_fn=lambda device: device.scaled_humidity,
|
||||
instance_check=Humidity,
|
||||
@@ -154,6 +161,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[LightLevel](
|
||||
key="light_level",
|
||||
supported_fn=lambda device: device.light_level is not None,
|
||||
update_key="lightlevel",
|
||||
value_fn=lambda device: device.scaled_light_level,
|
||||
instance_check=LightLevel,
|
||||
@@ -163,6 +171,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Power](
|
||||
key="power",
|
||||
supported_fn=lambda device: device.power is not None,
|
||||
update_key="power",
|
||||
value_fn=lambda device: device.power,
|
||||
instance_check=Power,
|
||||
@@ -172,6 +181,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Pressure](
|
||||
key="pressure",
|
||||
supported_fn=lambda device: device.pressure is not None,
|
||||
update_key="pressure",
|
||||
value_fn=lambda device: device.pressure,
|
||||
instance_check=Pressure,
|
||||
@@ -181,6 +191,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Temperature](
|
||||
key="temperature",
|
||||
supported_fn=lambda device: device.temperature is not None,
|
||||
update_key="temperature",
|
||||
value_fn=lambda device: device.scaled_temperature,
|
||||
instance_check=Temperature,
|
||||
@@ -190,6 +201,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[Time](
|
||||
key="last_set",
|
||||
supported_fn=lambda device: device.last_set is not None,
|
||||
update_key="lastset",
|
||||
value_fn=lambda device: dt_util.parse_datetime(device.last_set),
|
||||
instance_check=Time,
|
||||
@@ -197,6 +209,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[SensorResources](
|
||||
key="battery",
|
||||
supported_fn=lambda device: device.battery is not None,
|
||||
update_key="battery",
|
||||
value_fn=lambda device: device.battery,
|
||||
name_suffix="Battery",
|
||||
@@ -208,6 +221,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
|
||||
),
|
||||
DeconzSensorDescription[SensorResources](
|
||||
key="internal_temperature",
|
||||
supported_fn=lambda device: device.internal_temperature is not None,
|
||||
update_key="temperature",
|
||||
value_fn=lambda device: device.internal_temperature,
|
||||
name_suffix="Temperature",
|
||||
@@ -268,7 +282,7 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
no_sensor_data = False
|
||||
if description.value_fn(sensor) is None:
|
||||
if not description.supported_fn(sensor):
|
||||
no_sensor_data = True
|
||||
|
||||
if description.instance_check is None:
|
||||
|
||||
@@ -35,6 +35,7 @@ async def async_setup_entry(
|
||||
"devolo.model.Thermostat:Valve",
|
||||
"devolo.model.Room:Thermostat",
|
||||
"devolo.model.Eurotronic:Spirit:Device",
|
||||
"unk.model.Danfoss:Thermostat",
|
||||
):
|
||||
entities.append(
|
||||
DevoloClimateDeviceEntity(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "DLNA Digital Media Renderer",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"requirements": ["async-upnp-client==0.32.1"],
|
||||
"requirements": ["async-upnp-client==0.32.2"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "DLNA Digital Media Server",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"requirements": ["async-upnp-client==0.32.1"],
|
||||
"requirements": ["async-upnp-client==0.32.2"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -95,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
hass.config.time_zone,
|
||||
async_get_clientsession(hass),
|
||||
client_session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
# Authenticate, build sensors
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "eight_sleep",
|
||||
"name": "Eight Sleep",
|
||||
"documentation": "https://www.home-assistant.io/integrations/eight_sleep",
|
||||
"requirements": ["pyeight==0.3.0"],
|
||||
"requirements": ["pyeight==0.3.2"],
|
||||
"codeowners": ["@mezz64", "@raman325"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyeight"],
|
||||
|
||||
@@ -7,11 +7,11 @@ import logging
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import async_timeout
|
||||
from elkm1_lib.elements import Element
|
||||
from elkm1_lib.elk import Elk
|
||||
from elkm1_lib.util import parse_url
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -96,6 +96,11 @@ SET_TIME_SERVICE_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def hostname_from_url(url: str) -> str:
|
||||
"""Return the hostname from a url."""
|
||||
return parse_url(url)[1]
|
||||
|
||||
|
||||
def _host_validator(config: dict[str, str]) -> dict[str, str]:
|
||||
"""Validate that a host is properly configured."""
|
||||
if config[CONF_HOST].startswith("elks://"):
|
||||
@@ -231,7 +236,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Elk-M1 Control from a config entry."""
|
||||
conf: MappingProxyType[str, Any] = entry.data
|
||||
|
||||
host = urlparse(entry.data[CONF_HOST]).hostname
|
||||
host = hostname_from_url(entry.data[CONF_HOST])
|
||||
|
||||
_LOGGER.debug("Setting up elkm1 %s", conf["host"])
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from elkm1_lib.discovery import ElkSystem
|
||||
from elkm1_lib.elk import Elk
|
||||
@@ -26,7 +25,7 @@ from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.network import is_ip_address
|
||||
|
||||
from . import async_wait_for_elk_to_sync
|
||||
from . import async_wait_for_elk_to_sync, hostname_from_url
|
||||
from .const import CONF_AUTO_CONFIGURE, DISCOVER_SCAN_TIMEOUT, DOMAIN, LOGIN_TIMEOUT
|
||||
from .discovery import (
|
||||
_short_mac,
|
||||
@@ -170,7 +169,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if (
|
||||
entry.unique_id == mac
|
||||
or urlparse(entry.data[CONF_HOST]).hostname == host
|
||||
or hostname_from_url(entry.data[CONF_HOST]) == host
|
||||
):
|
||||
if async_update_entry_from_discovery(self.hass, entry, device):
|
||||
self.hass.async_create_task(
|
||||
@@ -214,7 +213,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
current_unique_ids = self._async_current_ids()
|
||||
current_hosts = {
|
||||
urlparse(entry.data[CONF_HOST]).hostname
|
||||
hostname_from_url(entry.data[CONF_HOST])
|
||||
for entry in self._async_current_entries(include_ignore=False)
|
||||
}
|
||||
discovered_devices = await async_discover_devices(
|
||||
@@ -344,7 +343,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if self._url_already_configured(url):
|
||||
return self.async_abort(reason="address_already_configured")
|
||||
|
||||
host = urlparse(url).hostname
|
||||
host = hostname_from_url(url)
|
||||
_LOGGER.debug(
|
||||
"Importing is trying to fill unique id from discovery for %s", host
|
||||
)
|
||||
@@ -367,10 +366,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
def _url_already_configured(self, url: str) -> bool:
|
||||
"""See if we already have a elkm1 matching user input configured."""
|
||||
existing_hosts = {
|
||||
urlparse(entry.data[CONF_HOST]).hostname
|
||||
hostname_from_url(entry.data[CONF_HOST])
|
||||
for entry in self._async_current_entries()
|
||||
}
|
||||
return urlparse(url).hostname in existing_hosts
|
||||
return hostname_from_url(url) in existing_hosts
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
@@ -22,6 +23,7 @@ from homeassistant.const import (
|
||||
VOLUME_GALLONS,
|
||||
VOLUME_LITERS,
|
||||
UnitOfEnergy,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -34,29 +36,35 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import unit_conversion
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, async_get_manager
|
||||
|
||||
SUPPORTED_STATE_CLASSES = [
|
||||
SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.MEASUREMENT,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
]
|
||||
VALID_ENERGY_UNITS = [
|
||||
}
|
||||
VALID_ENERGY_UNITS: set[str] = {
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
]
|
||||
VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS
|
||||
VALID_VOLUME_UNITS_WATER = [
|
||||
}
|
||||
VALID_ENERGY_UNITS_GAS = {
|
||||
VOLUME_CUBIC_FEET,
|
||||
VOLUME_CUBIC_METERS,
|
||||
*VALID_ENERGY_UNITS,
|
||||
}
|
||||
VALID_VOLUME_UNITS_WATER = {
|
||||
VOLUME_CUBIC_FEET,
|
||||
VOLUME_CUBIC_METERS,
|
||||
VOLUME_GALLONS,
|
||||
VOLUME_LITERS,
|
||||
]
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -252,8 +260,24 @@ class EnergyCostSensor(SensorEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _update_cost(self) -> None: # noqa: C901
|
||||
def _update_cost(self) -> None:
|
||||
"""Update incurred costs."""
|
||||
if self._adapter.source_type == "grid":
|
||||
valid_units = VALID_ENERGY_UNITS
|
||||
default_price_unit: str | None = UnitOfEnergy.KILO_WATT_HOUR
|
||||
|
||||
elif self._adapter.source_type == "gas":
|
||||
valid_units = VALID_ENERGY_UNITS_GAS
|
||||
# No conversion for gas.
|
||||
default_price_unit = None
|
||||
|
||||
elif self._adapter.source_type == "water":
|
||||
valid_units = VALID_VOLUME_UNITS_WATER
|
||||
if self.hass.config.units is METRIC_SYSTEM:
|
||||
default_price_unit = UnitOfVolume.CUBIC_METERS
|
||||
else:
|
||||
default_price_unit = UnitOfVolume.GALLONS
|
||||
|
||||
energy_state = self.hass.states.get(
|
||||
cast(str, self._config[self._adapter.stat_energy_key])
|
||||
)
|
||||
@@ -298,52 +322,27 @@ class EnergyCostSensor(SensorEntity):
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
||||
f"/{UnitOfEnergy.WATT_HOUR}"
|
||||
):
|
||||
energy_price *= 1000.0
|
||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||
).partition("/")[2]
|
||||
|
||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
||||
f"/{UnitOfEnergy.MEGA_WATT_HOUR}"
|
||||
):
|
||||
energy_price /= 1000.0
|
||||
|
||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
||||
f"/{UnitOfEnergy.GIGA_JOULE}"
|
||||
):
|
||||
energy_price /= 1000 / 3.6
|
||||
# For backwards compatibility we don't validate the unit of the price
|
||||
# If it is not valid, we assume it's our default price unit.
|
||||
if energy_price_unit not in valid_units:
|
||||
energy_price_unit = default_price_unit
|
||||
|
||||
else:
|
||||
energy_price_state = None
|
||||
energy_price = cast(float, self._config["number_energy_price"])
|
||||
energy_price_unit = default_price_unit
|
||||
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities are in place.
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
if self._adapter.source_type == "grid":
|
||||
if energy_unit not in VALID_ENERGY_UNITS:
|
||||
energy_unit = None
|
||||
|
||||
elif self._adapter.source_type == "gas":
|
||||
if energy_unit not in VALID_ENERGY_UNITS_GAS:
|
||||
energy_unit = None
|
||||
|
||||
elif self._adapter.source_type == "water":
|
||||
if energy_unit not in VALID_VOLUME_UNITS_WATER:
|
||||
energy_unit = None
|
||||
|
||||
if energy_unit == UnitOfEnergy.WATT_HOUR:
|
||||
energy_price /= 1000
|
||||
elif energy_unit == UnitOfEnergy.MEGA_WATT_HOUR:
|
||||
energy_price *= 1000
|
||||
elif energy_unit == UnitOfEnergy.GIGA_JOULE:
|
||||
energy_price *= 1000 / 3.6
|
||||
|
||||
if energy_unit is None:
|
||||
if energy_unit is None or energy_unit not in valid_units:
|
||||
if not self._wrong_unit_reported:
|
||||
self._wrong_unit_reported = True
|
||||
_LOGGER.warning(
|
||||
@@ -373,10 +372,30 @@ class EnergyCostSensor(SensorEntity):
|
||||
energy_state_copy = copy.copy(energy_state)
|
||||
energy_state_copy.state = "0.0"
|
||||
self._reset(energy_state_copy)
|
||||
|
||||
# Update with newly incurred cost
|
||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||
cur_value = cast(float, self._attr_native_value)
|
||||
self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price
|
||||
|
||||
if energy_price_unit is None:
|
||||
converted_energy_price = energy_price
|
||||
else:
|
||||
if self._adapter.source_type == "grid":
|
||||
converter: Callable[
|
||||
[float, str, str], float
|
||||
] = unit_conversion.EnergyConverter.convert
|
||||
elif self._adapter.source_type in ("gas", "water"):
|
||||
converter = unit_conversion.VolumeConverter.convert
|
||||
|
||||
converted_energy_price = converter(
|
||||
energy_price,
|
||||
energy_unit,
|
||||
energy_price_unit,
|
||||
)
|
||||
|
||||
self._attr_native_value = (
|
||||
cur_value + (energy - old_energy_value) * converted_energy_price
|
||||
)
|
||||
|
||||
self._last_energy_sensor_state = energy_state
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ SENSORS = (
|
||||
key="seven_days_production",
|
||||
name="Last Seven Days Energy Production",
|
||||
native_unit_of_measurement=ENERGY_WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -54,14 +54,14 @@ SENSORS = (
|
||||
key="daily_consumption",
|
||||
name="Today's Energy Consumption",
|
||||
native_unit_of_measurement=ENERGY_WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="seven_days_consumption",
|
||||
name="Last Seven Days Energy Consumption",
|
||||
native_unit_of_measurement=ENERGY_WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -249,6 +249,8 @@ async def async_setup_entry( # noqa: C901
|
||||
|
||||
async def on_disconnect() -> None:
|
||||
"""Run disconnect callbacks on API disconnect."""
|
||||
name = entry_data.device_info.name if entry_data.device_info else host
|
||||
_LOGGER.debug("%s: %s disconnected, running disconnected callbacks", name, host)
|
||||
for disconnect_cb in entry_data.disconnect_callbacks:
|
||||
disconnect_cb()
|
||||
entry_data.disconnect_callbacks = []
|
||||
|
||||
@@ -30,13 +30,15 @@ def _async_can_connect_factory(
|
||||
@hass_callback
|
||||
def _async_can_connect() -> bool:
|
||||
"""Check if a given source can make another connection."""
|
||||
can_connect = bool(entry_data.available and entry_data.ble_connections_free)
|
||||
_LOGGER.debug(
|
||||
"Checking if %s can connect, available=%s, ble_connections_free=%s",
|
||||
"%s: Checking can connect, available=%s, ble_connections_free=%s result=%s",
|
||||
source,
|
||||
entry_data.available,
|
||||
entry_data.ble_connections_free,
|
||||
can_connect,
|
||||
)
|
||||
return bool(entry_data.available and entry_data.ble_connections_free)
|
||||
return can_connect
|
||||
|
||||
return _async_can_connect
|
||||
|
||||
@@ -55,7 +57,7 @@ async def async_connect_scanner(
|
||||
version = entry_data.device_info.bluetooth_proxy_version
|
||||
connectable = version >= 2
|
||||
_LOGGER.debug(
|
||||
"Connecting scanner for %s, version=%s, connectable=%s",
|
||||
"%s: Connecting scanner version=%s, connectable=%s",
|
||||
source,
|
||||
version,
|
||||
connectable,
|
||||
|
||||
@@ -7,7 +7,13 @@ import logging
|
||||
from typing import Any, TypeVar, cast
|
||||
import uuid
|
||||
|
||||
from aioesphomeapi import (
|
||||
ESP_CONNECTION_ERROR_DESCRIPTION,
|
||||
ESPHOME_GATT_ERRORS,
|
||||
BLEConnectionError,
|
||||
)
|
||||
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
|
||||
from aioesphomeapi.core import BluetoothGATTAPIError
|
||||
import async_timeout
|
||||
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||
from bleak.backends.client import BaseBleakClient, NotifyCallback
|
||||
@@ -60,7 +66,7 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType:
|
||||
if disconnected_event.is_set():
|
||||
task.cancel()
|
||||
raise BleakError(
|
||||
f"{self._ble_device.name} ({self._ble_device.address}): " # pylint: disable=protected-access
|
||||
f"{self._source}: {self._ble_device.name} - {self._ble_device.address}: " # pylint: disable=protected-access
|
||||
"Disconnected during operation"
|
||||
)
|
||||
return next(iter(done)).result()
|
||||
@@ -78,6 +84,24 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
|
||||
return await func(self, *args, **kwargs)
|
||||
except TimeoutAPIError as err:
|
||||
raise asyncio.TimeoutError(str(err)) from err
|
||||
except BluetoothGATTAPIError as ex:
|
||||
# If the device disconnects in the middle of an operation
|
||||
# be sure to mark it as disconnected so any library using
|
||||
# the proxy knows to reconnect.
|
||||
#
|
||||
# Because callbacks are delivered asynchronously it's possible
|
||||
# that we find out about the disconnection during the operation
|
||||
# before the callback is delivered.
|
||||
if ex.error.error == -1:
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected during %s operation",
|
||||
self._source, # pylint: disable=protected-access
|
||||
self._ble_device.name, # pylint: disable=protected-access
|
||||
self._ble_device.address, # pylint: disable=protected-access
|
||||
func.__name__,
|
||||
)
|
||||
self._async_ble_device_disconnected() # pylint: disable=protected-access
|
||||
raise BleakError(str(ex)) from ex
|
||||
except APIConnectionError as err:
|
||||
raise BleakError(str(err)) from err
|
||||
|
||||
@@ -119,25 +143,41 @@ class ESPHomeClient(BaseBleakClient):
|
||||
self._cancel_connection_state()
|
||||
except (AssertionError, ValueError) as ex:
|
||||
_LOGGER.debug(
|
||||
"Failed to unsubscribe from connection state (likely connection dropped): %s",
|
||||
"%s: %s - %s: Failed to unsubscribe from connection state (likely connection dropped): %s",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
ex,
|
||||
)
|
||||
self._cancel_connection_state = None
|
||||
|
||||
def _async_ble_device_disconnected(self) -> None:
|
||||
"""Handle the BLE device disconnecting from the ESP."""
|
||||
_LOGGER.debug("%s: BLE device disconnected", self._source)
|
||||
self._is_connected = False
|
||||
was_connected = self._is_connected
|
||||
self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
|
||||
self._is_connected = False
|
||||
self._notify_cancels.clear()
|
||||
if self._disconnected_event:
|
||||
self._disconnected_event.set()
|
||||
self._disconnected_event = None
|
||||
self._async_call_bleak_disconnected_callback()
|
||||
if was_connected:
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: BLE device disconnected",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self._async_call_bleak_disconnected_callback()
|
||||
self._unsubscribe_connection_state()
|
||||
|
||||
def _async_esp_disconnected(self) -> None:
|
||||
"""Handle the esp32 client disconnecting from hass."""
|
||||
_LOGGER.debug("%s: ESP device disconnected", self._source)
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: ESP device disconnected",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self.entry_data.disconnect_callbacks.remove(self._async_esp_disconnected)
|
||||
self._async_ble_device_disconnected()
|
||||
|
||||
@@ -167,7 +207,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
) -> None:
|
||||
"""Handle a connect or disconnect."""
|
||||
_LOGGER.debug(
|
||||
"Connection state changed: connected=%s mtu=%s error=%s",
|
||||
"%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
connected,
|
||||
mtu,
|
||||
error,
|
||||
@@ -182,8 +225,19 @@ class ESPHomeClient(BaseBleakClient):
|
||||
return
|
||||
|
||||
if error:
|
||||
try:
|
||||
ble_connection_error = BLEConnectionError(error)
|
||||
ble_connection_error_name = ble_connection_error.name
|
||||
human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error]
|
||||
except (KeyError, ValueError):
|
||||
ble_connection_error_name = str(error)
|
||||
human_error = ESPHOME_GATT_ERRORS.get(
|
||||
error, f"Unknown error code {error}"
|
||||
)
|
||||
connected_future.set_exception(
|
||||
BleakError(f"Error while connecting: {error}")
|
||||
BleakError(
|
||||
f"Error {ble_connection_error_name} while connecting: {human_error}"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
@@ -191,6 +245,12 @@ class ESPHomeClient(BaseBleakClient):
|
||||
connected_future.set_exception(BleakError("Disconnected"))
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: connected, registering for disconnected callbacks",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self.entry_data.disconnect_callbacks.append(self._async_esp_disconnected)
|
||||
connected_future.set_result(connected)
|
||||
|
||||
@@ -218,7 +278,10 @@ class ESPHomeClient(BaseBleakClient):
|
||||
if self.entry_data.ble_connections_free:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"%s: Out of connection slots, waiting for a free one", self._source
|
||||
"%s: %s - %s: Out of connection slots, waiting for a free one",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
async with async_timeout.timeout(timeout):
|
||||
await self.entry_data.wait_for_ble_connections_free()
|
||||
@@ -255,25 +318,34 @@ class ESPHomeClient(BaseBleakClient):
|
||||
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
"""
|
||||
address_as_int = self._address_as_int
|
||||
domain_data = self.domain_data
|
||||
entry_data = self.entry_data
|
||||
if dangerous_use_bleak_cache and (
|
||||
cached_services := domain_data.get_gatt_services_cache(address_as_int)
|
||||
cached_services := entry_data.get_gatt_services_cache(address_as_int)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Cached services hit for %s - %s",
|
||||
"%s: %s - %s: Cached services hit",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
self.services = cached_services
|
||||
return self.services
|
||||
_LOGGER.debug(
|
||||
"Cached services miss for %s - %s",
|
||||
"%s: %s - %s: Cached services miss",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
esphome_services = await self._client.bluetooth_gatt_get_services(
|
||||
address_as_int
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: %s - %s: Got services: %s",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
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:
|
||||
@@ -297,11 +369,12 @@ class ESPHomeClient(BaseBleakClient):
|
||||
)
|
||||
self.services = services
|
||||
_LOGGER.debug(
|
||||
"Cached services saved for %s - %s",
|
||||
"%s: %s - %s: Cached services saved",
|
||||
self._source,
|
||||
self._ble_device.name,
|
||||
self._ble_device.address,
|
||||
)
|
||||
domain_data.set_gatt_services_cache(address_as_int, services)
|
||||
entry_data.set_gatt_services_cache(address_as_int, services)
|
||||
return services
|
||||
|
||||
def _resolve_characteristic(
|
||||
@@ -410,12 +483,20 @@ class ESPHomeClient(BaseBleakClient):
|
||||
UUID or directly by the BleakGATTCharacteristic object representing it.
|
||||
callback (function): The function to be called on notification.
|
||||
"""
|
||||
ble_handle = characteristic.handle
|
||||
if ble_handle in self._notify_cancels:
|
||||
raise BleakError(
|
||||
"Notifications are already enabled on "
|
||||
f"service:{characteristic.service_uuid} "
|
||||
f"characteristic:{characteristic.uuid} "
|
||||
f"handle:{ble_handle}"
|
||||
)
|
||||
cancel_coro = await self._client.bluetooth_gatt_start_notify(
|
||||
self._address_as_int,
|
||||
characteristic.handle,
|
||||
ble_handle,
|
||||
lambda handle, data: callback(data),
|
||||
)
|
||||
self._notify_cancels[characteristic.handle] = cancel_coro
|
||||
self._notify_cancels[ble_handle] = cancel_coro
|
||||
|
||||
@api_error_as_bleak_error
|
||||
async def stop_notify(
|
||||
@@ -430,5 +511,7 @@ class ESPHomeClient(BaseBleakClient):
|
||||
directly by the BleakGATTCharacteristic object representing it.
|
||||
"""
|
||||
characteristic = self._resolve_characteristic(char_specifier)
|
||||
coro = self._notify_cancels.pop(characteristic.handle)
|
||||
await coro()
|
||||
# Do not raise KeyError if notifications are not enabled on this characteristic
|
||||
# to be consistent with the behavior of the BlueZ backend
|
||||
if coro := self._notify_cancels.pop(characteristic.handle, None):
|
||||
await coro()
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
from aioesphomeapi import BluetoothLEAdvertisement
|
||||
from bleak.backends.device import BLEDevice
|
||||
@@ -19,9 +20,19 @@ from homeassistant.components.bluetooth import (
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
TWO_CHAR = re.compile("..")
|
||||
|
||||
# The maximum time between advertisements for a device to be considered
|
||||
# stale when the advertisement tracker can determine the interval for
|
||||
# connectable devices.
|
||||
#
|
||||
# BlueZ uses 180 seconds by default but we give it a bit more time
|
||||
# to account for the esp32's bluetooth stack being a bit slower
|
||||
# than BlueZ's.
|
||||
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195
|
||||
|
||||
|
||||
class ESPHomeScanner(BaseHaScanner):
|
||||
"""Scanner for esphome."""
|
||||
@@ -44,8 +55,12 @@ class ESPHomeScanner(BaseHaScanner):
|
||||
self._connector = connector
|
||||
self._connectable = connectable
|
||||
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
|
||||
self._fallback_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
|
||||
if connectable:
|
||||
self._details["connector"] = connector
|
||||
self._fallback_seconds = (
|
||||
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_setup(self) -> CALLBACK_TYPE:
|
||||
@@ -60,7 +75,7 @@ class ESPHomeScanner(BaseHaScanner):
|
||||
expired = [
|
||||
address
|
||||
for address, timestamp in self._discovered_device_timestamps.items()
|
||||
if now - timestamp > FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
|
||||
if now - timestamp > self._fallback_seconds
|
||||
]
|
||||
for address in expired:
|
||||
del self._discovered_device_advertisement_datas[address]
|
||||
@@ -84,7 +99,7 @@ class ESPHomeScanner(BaseHaScanner):
|
||||
@callback
|
||||
def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None:
|
||||
"""Call the registered callback."""
|
||||
now = time.monotonic()
|
||||
now = monotonic_time_coarse()
|
||||
address = ":".join(TWO_CHAR.findall("%012X" % adv.address)) # must be upper
|
||||
name = adv.name
|
||||
if prev_discovery := self._discovered_device_advertisement_datas.get(address):
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"""Support for esphome domain data."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from bleak.backends.service import BleakGATTServiceCollection
|
||||
from lru import LRU # pylint: disable=no-name-in-module
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
@@ -17,7 +13,6 @@ from .entry_data import RuntimeEntryData
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
DOMAIN = "esphome"
|
||||
MAX_CACHED_SERVICES = 128
|
||||
|
||||
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
|
||||
|
||||
@@ -29,21 +24,6 @@ class DomainData:
|
||||
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
|
||||
_stores: dict[str, Store] = field(default_factory=dict)
|
||||
_entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict)
|
||||
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
|
||||
default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return]
|
||||
)
|
||||
|
||||
def get_gatt_services_cache(
|
||||
self, address: int
|
||||
) -> BleakGATTServiceCollection | None:
|
||||
"""Get the BleakGATTServiceCollection for the given address."""
|
||||
return self._gatt_services_cache.get(address)
|
||||
|
||||
def set_gatt_services_cache(
|
||||
self, address: int, services: BleakGATTServiceCollection
|
||||
) -> None:
|
||||
"""Set the BleakGATTServiceCollection for the given address."""
|
||||
self._gatt_services_cache[address] = services
|
||||
|
||||
def get_by_unique_id(self, unique_id: str) -> ConfigEntry:
|
||||
"""Get the config entry by its unique ID."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, MutableMapping
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
@@ -30,6 +30,8 @@ from aioesphomeapi import (
|
||||
UserService,
|
||||
)
|
||||
from aioesphomeapi.model import ButtonInfo
|
||||
from bleak.backends.service import BleakGATTServiceCollection
|
||||
from lru import LRU # pylint: disable=no-name-in-module
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -57,6 +59,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = {
|
||||
SwitchInfo: Platform.SWITCH,
|
||||
TextSensorInfo: Platform.SENSOR,
|
||||
}
|
||||
MAX_CACHED_SERVICES = 128
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,12 +95,37 @@ class RuntimeEntryData:
|
||||
_ble_connection_free_futures: list[asyncio.Future[int]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
|
||||
default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return]
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device."""
|
||||
return self.device_info.name if self.device_info else self.entry_id
|
||||
|
||||
def get_gatt_services_cache(
|
||||
self, address: int
|
||||
) -> BleakGATTServiceCollection | None:
|
||||
"""Get the BleakGATTServiceCollection for the given address."""
|
||||
return self._gatt_services_cache.get(address)
|
||||
|
||||
def set_gatt_services_cache(
|
||||
self, address: int, services: BleakGATTServiceCollection
|
||||
) -> None:
|
||||
"""Set the BleakGATTServiceCollection for the given address."""
|
||||
self._gatt_services_cache[address] = services
|
||||
|
||||
@callback
|
||||
def async_update_ble_connection_limits(self, free: int, limit: int) -> None:
|
||||
"""Update the BLE connection limits."""
|
||||
name = self.device_info.name if self.device_info else self.entry_id
|
||||
_LOGGER.debug("%s: BLE connection limits: %s/%s", name, free, limit)
|
||||
_LOGGER.debug(
|
||||
"%s: BLE connection limits: used=%s free=%s limit=%s",
|
||||
self.name,
|
||||
limit - free,
|
||||
free,
|
||||
limit,
|
||||
)
|
||||
self.ble_connections_free = free
|
||||
self.ble_connections_limit = limit
|
||||
if free:
|
||||
@@ -168,7 +196,8 @@ class RuntimeEntryData:
|
||||
subscription_key = (type(state), state.key)
|
||||
self.state[type(state)][state.key] = state
|
||||
_LOGGER.debug(
|
||||
"Dispatching update with key %s: %s",
|
||||
"%s: dispatching update with key %s: %s",
|
||||
self.name,
|
||||
subscription_key,
|
||||
state,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "ESPHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/esphome",
|
||||
"requirements": ["aioesphomeapi==11.2.0"],
|
||||
"requirements": ["aioesphomeapi==11.4.3"],
|
||||
"zeroconf": ["_esphomelib._tcp.local."],
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"codeowners": ["@OttoWinter", "@jesserockz"],
|
||||
|
||||
@@ -84,6 +84,7 @@ FIBARO_TYPEMAP = {
|
||||
"com.fibaro.thermostatDanfoss": Platform.CLIMATE,
|
||||
"com.fibaro.doorLock": Platform.LOCK,
|
||||
"com.fibaro.binarySensor": Platform.BINARY_SENSOR,
|
||||
"com.fibaro.accelerometer": Platform.BINARY_SENSOR,
|
||||
}
|
||||
|
||||
DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Support for Fibaro binary sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -28,6 +30,11 @@ SENSOR_TYPES = {
|
||||
"com.fibaro.smokeSensor": ["Smoke", "mdi:smoking", BinarySensorDeviceClass.SMOKE],
|
||||
"com.fibaro.FGMS001": ["Motion", "mdi:run", BinarySensorDeviceClass.MOTION],
|
||||
"com.fibaro.heatDetector": ["Heat", "mdi:fire", BinarySensorDeviceClass.HEAT],
|
||||
"com.fibaro.accelerometer": [
|
||||
"Moving",
|
||||
"mdi:axis-arrow",
|
||||
BinarySensorDeviceClass.MOVING,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -55,15 +62,50 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity):
|
||||
"""Initialize the binary_sensor."""
|
||||
super().__init__(fibaro_device)
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
|
||||
stype = None
|
||||
self._own_extra_state_attributes: Mapping[str, Any] = {}
|
||||
self._fibaro_sensor_type = None
|
||||
if fibaro_device.type in SENSOR_TYPES:
|
||||
stype = fibaro_device.type
|
||||
self._fibaro_sensor_type = fibaro_device.type
|
||||
elif fibaro_device.baseType in SENSOR_TYPES:
|
||||
stype = fibaro_device.baseType
|
||||
if stype:
|
||||
self._attr_device_class = SENSOR_TYPES[stype][2]
|
||||
self._attr_icon = SENSOR_TYPES[stype][1]
|
||||
self._fibaro_sensor_type = fibaro_device.baseType
|
||||
if self._fibaro_sensor_type:
|
||||
self._attr_device_class = SENSOR_TYPES[self._fibaro_sensor_type][2]
|
||||
self._attr_icon = SENSOR_TYPES[self._fibaro_sensor_type][1]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the extra state attributes of the device."""
|
||||
return super().extra_state_attributes | self._own_extra_state_attributes
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data and update the state."""
|
||||
self._attr_is_on = self.current_binary_state
|
||||
if self._fibaro_sensor_type == "com.fibaro.accelerometer":
|
||||
# Accelerator sensors have values for the three axis x, y and z
|
||||
moving_values = self._get_moving_values()
|
||||
self._attr_is_on = self._is_moving(moving_values)
|
||||
self._own_extra_state_attributes = self._get_xyz_moving(moving_values)
|
||||
else:
|
||||
self._attr_is_on = self.current_binary_state
|
||||
|
||||
def _get_xyz_moving(self, moving_values: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""Return x y z values of the accelerator sensor value."""
|
||||
attrs = {}
|
||||
for axis_name in ("x", "y", "z"):
|
||||
attrs[axis_name] = float(moving_values[axis_name])
|
||||
return attrs
|
||||
|
||||
def _is_moving(self, moving_values: Mapping[str, Any]) -> bool:
|
||||
"""Return that a moving is detected when one axis reports a value."""
|
||||
for axis_name in ("x", "y", "z"):
|
||||
if float(moving_values[axis_name]) != 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_moving_values(self) -> Mapping[str, Any]:
|
||||
"""Get the moving values of the accelerator sensor in a dict."""
|
||||
value = self.fibaro_device.properties.value
|
||||
if isinstance(value, str):
|
||||
# HC2 returns dict as str
|
||||
return json.loads(value)
|
||||
# HC3 returns a real dict
|
||||
return value
|
||||
|
||||
@@ -612,7 +612,7 @@ class TimeSMAFilter(Filter, SensorEntity):
|
||||
|
||||
moving_sum = 0
|
||||
start = new_state.timestamp - self._time_window
|
||||
prev_state = self.last_leak or self.queue[0]
|
||||
prev_state = self.last_leak if self.last_leak is not None else self.queue[0]
|
||||
for state in self.queue:
|
||||
moving_sum += (state.timestamp - start).total_seconds() * prev_state.state
|
||||
start = state.timestamp
|
||||
|
||||
@@ -79,6 +79,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity):
|
||||
"type",
|
||||
"responder_mode",
|
||||
"can_respond_until",
|
||||
"task_ids",
|
||||
):
|
||||
if data.get(value):
|
||||
attr[value] = data[value]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Flo device object."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -40,14 +39,10 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
async def _async_update_data(self):
|
||||
"""Update data via library."""
|
||||
try:
|
||||
async with timeout(10):
|
||||
await asyncio.gather(
|
||||
*[
|
||||
self.send_presence_ping(),
|
||||
self._update_device(),
|
||||
self._update_consumption_data(),
|
||||
]
|
||||
)
|
||||
async with timeout(20):
|
||||
await self.send_presence_ping()
|
||||
await self._update_device()
|
||||
await self._update_consumption_data()
|
||||
except (RequestError) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ DEFAULT_NAME = "Flume Sensor"
|
||||
|
||||
# Flume API limits individual endpoints to 120 queries per hour
|
||||
NOTIFICATION_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
DEVICE_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
DEVICE_SCAN_INTERVAL = timedelta(minutes=5)
|
||||
DEVICE_CONNECTION_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
@@ -14,5 +14,6 @@ def get_valid_flume_devices(flume_devices: FlumeDeviceList) -> list[dict[str, An
|
||||
return [
|
||||
device
|
||||
for device in flume_devices.device_list
|
||||
if KEY_DEVICE_LOCATION_NAME in device[KEY_DEVICE_LOCATION]
|
||||
if KEY_DEVICE_LOCATION in device
|
||||
and KEY_DEVICE_LOCATION_NAME in device[KEY_DEVICE_LOCATION]
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/flux_led",
|
||||
"requirements": ["flux_led==0.28.32"],
|
||||
"requirements": ["flux_led==0.28.34"],
|
||||
"quality_scale": "platinum",
|
||||
"codeowners": ["@icemanch", "@bdraco"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20221027.0"],
|
||||
"requirements": ["home-assistant-frontend==20221108.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -20,7 +20,7 @@ start_application:
|
||||
device:
|
||||
integration: fully_kiosk
|
||||
fields:
|
||||
url:
|
||||
application:
|
||||
name: Application
|
||||
description: Package name of the application to start.
|
||||
example: "de.ozerov.fully"
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from gcal_sync.api import SyncEventsRequest
|
||||
from gcal_sync.api import GoogleCalendarService, ListEventsRequest, SyncEventsRequest
|
||||
from gcal_sync.exceptions import ApiException
|
||||
from gcal_sync.model import DateOrDatetime, Event
|
||||
from gcal_sync.model import AccessRole, DateOrDatetime, Event
|
||||
from gcal_sync.store import ScopedCalendarStore
|
||||
from gcal_sync.sync import CalendarEventSyncManager
|
||||
from gcal_sync.timeline import Timeline
|
||||
@@ -196,21 +197,36 @@ async def async_setup_entry(
|
||||
entity_registry.async_remove(
|
||||
entity_entry.entity_id,
|
||||
)
|
||||
request_template = SyncEventsRequest(
|
||||
calendar_id=calendar_id,
|
||||
search=data.get(CONF_SEARCH),
|
||||
start_time=dt_util.now() + SYNC_EVENT_MIN_TIME,
|
||||
)
|
||||
sync = CalendarEventSyncManager(
|
||||
calendar_service,
|
||||
store=ScopedCalendarStore(store, unique_id or entity_name),
|
||||
request_template=request_template,
|
||||
)
|
||||
coordinator = CalendarUpdateCoordinator(
|
||||
hass,
|
||||
sync,
|
||||
data[CONF_NAME],
|
||||
)
|
||||
coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator
|
||||
# Prefer calendar sync down of resources when possible. However, sync does not work
|
||||
# for search. Also free-busy calendars denormalize recurring events as individual
|
||||
# events which is not efficient for sync
|
||||
if (
|
||||
search := data.get(CONF_SEARCH)
|
||||
or calendar_item.access_role == AccessRole.FREE_BUSY_READER
|
||||
):
|
||||
coordinator = CalendarQueryUpdateCoordinator(
|
||||
hass,
|
||||
calendar_service,
|
||||
data[CONF_NAME],
|
||||
calendar_id,
|
||||
search,
|
||||
)
|
||||
else:
|
||||
request_template = SyncEventsRequest(
|
||||
calendar_id=calendar_id,
|
||||
start_time=dt_util.now() + SYNC_EVENT_MIN_TIME,
|
||||
)
|
||||
sync = CalendarEventSyncManager(
|
||||
calendar_service,
|
||||
store=ScopedCalendarStore(store, unique_id or entity_name),
|
||||
request_template=request_template,
|
||||
)
|
||||
coordinator = CalendarSyncUpdateCoordinator(
|
||||
hass,
|
||||
sync,
|
||||
data[CONF_NAME],
|
||||
)
|
||||
entities.append(
|
||||
GoogleCalendarEntity(
|
||||
coordinator,
|
||||
@@ -242,8 +258,8 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class CalendarUpdateCoordinator(DataUpdateCoordinator[Timeline]):
|
||||
"""Coordinator for calendar RPC calls."""
|
||||
class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]):
|
||||
"""Coordinator for calendar RPC calls that use an efficient sync."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -251,7 +267,7 @@ class CalendarUpdateCoordinator(DataUpdateCoordinator[Timeline]):
|
||||
sync: CalendarEventSyncManager,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Create the Calendar event device."""
|
||||
"""Create the CalendarSyncUpdateCoordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
@@ -271,6 +287,87 @@ class CalendarUpdateCoordinator(DataUpdateCoordinator[Timeline]):
|
||||
dt_util.DEFAULT_TIME_ZONE
|
||||
)
|
||||
|
||||
async def async_get_events(
|
||||
self, start_date: datetime, end_date: datetime
|
||||
) -> Iterable[Event]:
|
||||
"""Get all events in a specific time frame."""
|
||||
if not self.data:
|
||||
raise HomeAssistantError(
|
||||
"Unable to get events: Sync from server has not completed"
|
||||
)
|
||||
return self.data.overlapping(
|
||||
dt_util.as_local(start_date),
|
||||
dt_util.as_local(end_date),
|
||||
)
|
||||
|
||||
@property
|
||||
def upcoming(self) -> Iterable[Event] | None:
|
||||
"""Return upcoming events if any."""
|
||||
if self.data:
|
||||
return self.data.active_after(dt_util.now())
|
||||
return None
|
||||
|
||||
|
||||
class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]):
|
||||
"""Coordinator for calendar RPC calls.
|
||||
|
||||
This sends a polling RPC, not using sync, as a workaround
|
||||
for limitations in the calendar API for supporting search.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
calendar_service: GoogleCalendarService,
|
||||
name: str,
|
||||
calendar_id: str,
|
||||
search: str | None,
|
||||
) -> None:
|
||||
"""Create the CalendarQueryUpdateCoordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=name,
|
||||
update_interval=MIN_TIME_BETWEEN_UPDATES,
|
||||
)
|
||||
self.calendar_service = calendar_service
|
||||
self.calendar_id = calendar_id
|
||||
self._search = search
|
||||
|
||||
async def async_get_events(
|
||||
self, start_date: datetime, end_date: datetime
|
||||
) -> Iterable[Event]:
|
||||
"""Get all events in a specific time frame."""
|
||||
request = ListEventsRequest(
|
||||
calendar_id=self.calendar_id,
|
||||
start_time=start_date,
|
||||
end_time=end_date,
|
||||
search=self._search,
|
||||
)
|
||||
result_items = []
|
||||
try:
|
||||
result = await self.calendar_service.async_list_events(request)
|
||||
async for result_page in result:
|
||||
result_items.extend(result_page.items)
|
||||
except ApiException as err:
|
||||
self.async_set_update_error(err)
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
return result_items
|
||||
|
||||
async def _async_update_data(self) -> list[Event]:
|
||||
"""Fetch data from API endpoint."""
|
||||
request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search)
|
||||
try:
|
||||
result = await self.calendar_service.async_list_events(request)
|
||||
except ApiException as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
return result.items
|
||||
|
||||
@property
|
||||
def upcoming(self) -> Iterable[Event] | None:
|
||||
"""Return the next upcoming event if any."""
|
||||
return self.data
|
||||
|
||||
|
||||
class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
|
||||
"""A calendar event entity."""
|
||||
@@ -279,7 +376,7 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CalendarUpdateCoordinator,
|
||||
coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator,
|
||||
calendar_id: str,
|
||||
data: dict[str, Any],
|
||||
entity_id: str,
|
||||
@@ -352,14 +449,7 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
if not (timeline := self.coordinator.data):
|
||||
raise HomeAssistantError(
|
||||
"Unable to get events: Sync from server has not completed"
|
||||
)
|
||||
result_items = timeline.overlapping(
|
||||
dt_util.as_local(start_date),
|
||||
dt_util.as_local(end_date),
|
||||
)
|
||||
result_items = await self.coordinator.async_get_events(start_date, end_date)
|
||||
return [
|
||||
_get_calendar_event(event)
|
||||
for event in filter(self._event_filter, result_items)
|
||||
@@ -367,14 +457,12 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
|
||||
|
||||
def _apply_coordinator_update(self) -> None:
|
||||
"""Copy state from the coordinator to this entity."""
|
||||
if (timeline := self.coordinator.data) and (
|
||||
api_event := next(
|
||||
filter(
|
||||
self._event_filter,
|
||||
timeline.active_after(dt_util.now()),
|
||||
),
|
||||
None,
|
||||
)
|
||||
if api_event := next(
|
||||
filter(
|
||||
self._event_filter,
|
||||
self.coordinator.upcoming or [],
|
||||
),
|
||||
None,
|
||||
):
|
||||
self._event = _get_calendar_event(api_event)
|
||||
(self._event.summary, self._offset_value) = extract_offset(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
||||
"requirements": ["gcal-sync==2.2.0", "oauth2client==4.1.3"],
|
||||
"requirements": ["gcal-sync==4.0.2", "oauth2client==4.1.3"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"]
|
||||
|
||||
@@ -7,6 +7,7 @@ import aiohttp
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from gspread import Client
|
||||
from gspread.utils import ValueInputOption
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
@@ -100,7 +101,7 @@ async def async_setup_service(hass: HomeAssistant) -> None:
|
||||
columns.append(key)
|
||||
worksheet.update_cell(1, len(columns), key)
|
||||
row.append(value)
|
||||
worksheet.append_row(row)
|
||||
worksheet.append_row(row, value_input_option=ValueInputOption.user_entered)
|
||||
|
||||
async def append_to_sheet(call: ServiceCall) -> None:
|
||||
"""Append new line of data to a Google Sheets document."""
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"time_type": "Time Type",
|
||||
"time": "Time",
|
||||
"avoid": "Avoid",
|
||||
"traffic_mode": "Traffic Mode",
|
||||
"transit_mode": "Transit Mode",
|
||||
"transit_routing_preference": "Transit Routing Preference",
|
||||
"units": "Units"
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"mode": "Travel Mode",
|
||||
"time": "Time",
|
||||
"time_type": "Time Type",
|
||||
"traffic_mode": "Traffic Mode",
|
||||
"transit_mode": "Transit Mode",
|
||||
"transit_routing_preference": "Transit Routing Preference",
|
||||
"units": "Units"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Growatt",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/growatt_server/",
|
||||
"requirements": ["growattServer==1.2.3"],
|
||||
"requirements": ["growattServer==1.2.4"],
|
||||
"codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"]
|
||||
|
||||
@@ -32,7 +32,7 @@ from .sensor_types.total import TOTAL_SENSOR_TYPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(minutes=1)
|
||||
SCAN_INTERVAL = datetime.timedelta(minutes=5)
|
||||
|
||||
|
||||
def get_device_list(api, config):
|
||||
@@ -159,7 +159,7 @@ class GrowattInverter(SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
result = self.probe.get_data(self.entity_description.api_key)
|
||||
result = self.probe.get_data(self.entity_description)
|
||||
if self.entity_description.precision is not None:
|
||||
result = round(result, self.entity_description.precision)
|
||||
return result
|
||||
@@ -168,7 +168,7 @@ class GrowattInverter(SensorEntity):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor, if any."""
|
||||
if self.entity_description.currency:
|
||||
return self.probe.get_data("currency")
|
||||
return self.probe.get_currency()
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
def update(self) -> None:
|
||||
@@ -187,6 +187,7 @@ class GrowattData:
|
||||
self.device_id = device_id
|
||||
self.plant_id = None
|
||||
self.data = {}
|
||||
self.previous_values = {}
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
@@ -254,9 +255,61 @@ class GrowattData:
|
||||
**mix_detail,
|
||||
**dashboard_values_for_mix,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Finished updating data for %s (%s)",
|
||||
self.device_id,
|
||||
self.growatt_type,
|
||||
)
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error("Unable to fetch data from Growatt server")
|
||||
|
||||
def get_data(self, variable):
|
||||
def get_currency(self):
|
||||
"""Get the currency."""
|
||||
return self.data.get("currency")
|
||||
|
||||
def get_data(self, entity_description):
|
||||
"""Get the data."""
|
||||
return self.data.get(variable)
|
||||
_LOGGER.debug(
|
||||
"Data request for: %s",
|
||||
entity_description.name,
|
||||
)
|
||||
variable = entity_description.api_key
|
||||
api_value = self.data.get(variable)
|
||||
previous_value = self.previous_values.get(variable)
|
||||
return_value = api_value
|
||||
|
||||
# If we have a 'drop threshold' specified, then check it and correct if needed
|
||||
if (
|
||||
entity_description.previous_value_drop_threshold is not None
|
||||
and previous_value is not None
|
||||
and api_value is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s - Drop threshold specified (%s), checking for drop... API Value: %s, Previous Value: %s",
|
||||
entity_description.name,
|
||||
entity_description.previous_value_drop_threshold,
|
||||
api_value,
|
||||
previous_value,
|
||||
)
|
||||
diff = float(api_value) - float(previous_value)
|
||||
|
||||
# Check if the value has dropped (negative value i.e. < 0) and it has only dropped by a
|
||||
# small amount, if so, use the previous value.
|
||||
# Note - The energy dashboard takes care of drops within 10% of the current value,
|
||||
# however if the value is low e.g. 0.2 and drops by 0.1 it classes as a reset.
|
||||
if -(entity_description.previous_value_drop_threshold) <= diff < 0:
|
||||
_LOGGER.debug(
|
||||
"Diff is negative, but only by a small amount therefore not a nightly reset, "
|
||||
"using previous value (%s) instead of api value (%s)",
|
||||
previous_value,
|
||||
api_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - No drop detected, using API value", entity_description.name
|
||||
)
|
||||
|
||||
self.previous_values[variable] = return_value
|
||||
|
||||
return return_value
|
||||
|
||||
@@ -241,5 +241,6 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
previous_value_drop_threshold=0.2,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,3 +19,4 @@ class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKey
|
||||
|
||||
precision: int | None = None
|
||||
currency: bool = False
|
||||
previous_value_drop_threshold: float | None = None
|
||||
|
||||
@@ -77,6 +77,7 @@ from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F4
|
||||
from .handler import HassIO, HassioAPIError, api_data
|
||||
from .http import HassIOView
|
||||
from .ingress import async_setup_ingress_view
|
||||
from .repairs import SupervisorRepairs
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -103,6 +104,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
|
||||
DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_SUPERVISOR_REPAIRS = "supervisor_repairs"
|
||||
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
ADDONS_COORDINATOR = "hassio_addons_coordinator"
|
||||
@@ -758,6 +760,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
|
||||
)
|
||||
|
||||
# Start listening for problems with supervisor and making repairs
|
||||
hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio)
|
||||
await repairs.setup()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -11,19 +11,26 @@ ATTR_CONFIG = "config"
|
||||
ATTR_DATA = "data"
|
||||
ATTR_DISCOVERY = "discovery"
|
||||
ATTR_ENABLE = "enable"
|
||||
ATTR_ENDPOINT = "endpoint"
|
||||
ATTR_FOLDERS = "folders"
|
||||
ATTR_HEALTHY = "healthy"
|
||||
ATTR_HOMEASSISTANT = "homeassistant"
|
||||
ATTR_INPUT = "input"
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_PANELS = "panels"
|
||||
ATTR_PASSWORD = "password"
|
||||
ATTR_RESULT = "result"
|
||||
ATTR_SUPPORTED = "supported"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_TITLE = "title"
|
||||
ATTR_UNHEALTHY = "unhealthy"
|
||||
ATTR_UNHEALTHY_REASONS = "unhealthy_reasons"
|
||||
ATTR_UNSUPPORTED = "unsupported"
|
||||
ATTR_UNSUPPORTED_REASONS = "unsupported_reasons"
|
||||
ATTR_UPDATE_KEY = "update_key"
|
||||
ATTR_USERNAME = "username"
|
||||
ATTR_UUID = "uuid"
|
||||
ATTR_WS_EVENT = "event"
|
||||
ATTR_ENDPOINT = "endpoint"
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_RESULT = "result"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
|
||||
X_AUTH_TOKEN = "X-Supervisor-Token"
|
||||
X_INGRESS_PATH = "X-Ingress-Path"
|
||||
@@ -38,6 +45,11 @@ WS_TYPE_EVENT = "supervisor/event"
|
||||
WS_TYPE_SUBSCRIBE = "supervisor/subscribe"
|
||||
|
||||
EVENT_SUPERVISOR_EVENT = "supervisor_event"
|
||||
EVENT_SUPERVISOR_UPDATE = "supervisor_update"
|
||||
EVENT_HEALTH_CHANGED = "health_changed"
|
||||
EVENT_SUPPORTED_CHANGED = "supported_changed"
|
||||
|
||||
UPDATE_KEY_SUPERVISOR = "supervisor"
|
||||
|
||||
ATTR_AUTO_UPDATE = "auto_update"
|
||||
ATTR_VERSION = "version"
|
||||
@@ -51,7 +63,6 @@ ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
|
||||
|
||||
DATA_KEY_ADDONS = "addons"
|
||||
DATA_KEY_OS = "os"
|
||||
DATA_KEY_SUPERVISOR = "supervisor"
|
||||
|
||||
@@ -190,6 +190,14 @@ class HassIO:
|
||||
"""
|
||||
return self.send_command(f"/discovery/{uuid}", method="get")
|
||||
|
||||
@api_data
|
||||
def get_resolution_info(self):
|
||||
"""Return data for Supervisor resolution center.
|
||||
|
||||
This method return a coroutine.
|
||||
"""
|
||||
return self.send_command("/resolution/info", method="get")
|
||||
|
||||
@_api_bool
|
||||
async def update_hass_api(self, http_config, refresh_token):
|
||||
"""Update Home Assistant API data on Hass.io."""
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Supervisor events monitor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_HEALTHY,
|
||||
ATTR_SUPPORTED,
|
||||
ATTR_UNHEALTHY,
|
||||
ATTR_UNHEALTHY_REASONS,
|
||||
ATTR_UNSUPPORTED,
|
||||
ATTR_UNSUPPORTED_REASONS,
|
||||
ATTR_UPDATE_KEY,
|
||||
ATTR_WS_EVENT,
|
||||
DOMAIN,
|
||||
EVENT_HEALTH_CHANGED,
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .handler import HassIO
|
||||
|
||||
ISSUE_ID_UNHEALTHY = "unhealthy_system"
|
||||
ISSUE_ID_UNSUPPORTED = "unsupported_system"
|
||||
|
||||
INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
|
||||
INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
|
||||
|
||||
UNSUPPORTED_REASONS = {
|
||||
"apparmor",
|
||||
"connectivity_check",
|
||||
"content_trust",
|
||||
"dbus",
|
||||
"dns_server",
|
||||
"docker_configuration",
|
||||
"docker_version",
|
||||
"cgroup_version",
|
||||
"job_conditions",
|
||||
"lxc",
|
||||
"network_manager",
|
||||
"os",
|
||||
"os_agent",
|
||||
"restart_policy",
|
||||
"software",
|
||||
"source_mods",
|
||||
"supervisor_version",
|
||||
"systemd",
|
||||
"systemd_journal",
|
||||
"systemd_resolved",
|
||||
}
|
||||
# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
|
||||
# provides no additional information beyond the unhealthy one then skip that repair.
|
||||
UNSUPPORTED_SKIP_REPAIR = {"privileged"}
|
||||
UNHEALTHY_REASONS = {
|
||||
"docker",
|
||||
"supervisor",
|
||||
"setup",
|
||||
"privileged",
|
||||
"untrusted",
|
||||
}
|
||||
|
||||
|
||||
class SupervisorRepairs:
|
||||
"""Create repairs from supervisor events."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
|
||||
"""Initialize supervisor repairs."""
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._unsupported_reasons: set[str] = set()
|
||||
self._unhealthy_reasons: set[str] = set()
|
||||
|
||||
@property
|
||||
def unhealthy_reasons(self) -> set[str]:
|
||||
"""Get unhealthy reasons. Returns empty set if system is healthy."""
|
||||
return self._unhealthy_reasons
|
||||
|
||||
@unhealthy_reasons.setter
|
||||
def unhealthy_reasons(self, reasons: set[str]) -> None:
|
||||
"""Set unhealthy reasons. Create or delete repairs as necessary."""
|
||||
for unhealthy in reasons - self.unhealthy_reasons:
|
||||
if unhealthy in UNHEALTHY_REASONS:
|
||||
translation_key = f"unhealthy_{unhealthy}"
|
||||
translation_placeholders = None
|
||||
else:
|
||||
translation_key = "unhealthy"
|
||||
translation_placeholders = {"reason": unhealthy}
|
||||
|
||||
async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
f"{ISSUE_ID_UNHEALTHY}_{unhealthy}",
|
||||
is_fixable=False,
|
||||
learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}",
|
||||
severity=IssueSeverity.CRITICAL,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
for fixed in self.unhealthy_reasons - reasons:
|
||||
async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}")
|
||||
|
||||
self._unhealthy_reasons = reasons
|
||||
|
||||
@property
|
||||
def unsupported_reasons(self) -> set[str]:
|
||||
"""Get unsupported reasons. Returns empty set if system is supported."""
|
||||
return self._unsupported_reasons
|
||||
|
||||
@unsupported_reasons.setter
|
||||
def unsupported_reasons(self, reasons: set[str]) -> None:
|
||||
"""Set unsupported reasons. Create or delete repairs as necessary."""
|
||||
for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
|
||||
if unsupported in UNSUPPORTED_REASONS:
|
||||
translation_key = f"unsupported_{unsupported}"
|
||||
translation_placeholders = None
|
||||
else:
|
||||
translation_key = "unsupported"
|
||||
translation_placeholders = {"reason": unsupported}
|
||||
|
||||
async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
f"{ISSUE_ID_UNSUPPORTED}_{unsupported}",
|
||||
is_fixable=False,
|
||||
learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR):
|
||||
async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}")
|
||||
|
||||
self._unsupported_reasons = reasons
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Create supervisor events listener."""
|
||||
await self.update()
|
||||
|
||||
async_dispatcher_connect(
|
||||
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs
|
||||
)
|
||||
|
||||
async def update(self) -> None:
|
||||
"""Update repairs from Supervisor resolution center."""
|
||||
data = await self._client.get_resolution_info()
|
||||
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
|
||||
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED])
|
||||
|
||||
@callback
|
||||
def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None:
|
||||
"""Create repairs from supervisor events."""
|
||||
if ATTR_WS_EVENT not in event:
|
||||
return
|
||||
|
||||
if (
|
||||
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
|
||||
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
|
||||
):
|
||||
self._hass.async_create_task(self.update())
|
||||
|
||||
elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED:
|
||||
self.unhealthy_reasons = (
|
||||
set()
|
||||
if event[ATTR_DATA][ATTR_HEALTHY]
|
||||
else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS])
|
||||
)
|
||||
|
||||
elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED:
|
||||
self.unsupported_reasons = (
|
||||
set()
|
||||
if event[ATTR_DATA][ATTR_SUPPORTED]
|
||||
else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
|
||||
)
|
||||
@@ -15,5 +15,115 @@
|
||||
"update_channel": "Update Channel",
|
||||
"version_api": "Version API"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"title": "Unhealthy system - {reason}",
|
||||
"description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_docker": {
|
||||
"title": "Unhealthy system - Docker misconfigured",
|
||||
"description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_supervisor": {
|
||||
"title": "Unhealthy system - Supervisor update failed",
|
||||
"description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_setup": {
|
||||
"title": "Unhealthy system - Setup failed",
|
||||
"description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_privileged": {
|
||||
"title": "Unhealthy system - Not privileged",
|
||||
"description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_untrusted": {
|
||||
"title": "Unhealthy system - Untrusted code",
|
||||
"description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported": {
|
||||
"title": "Unsupported system - {reason}",
|
||||
"description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_apparmor": {
|
||||
"title": "Unsupported system - AppArmor issues",
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_cgroup_version": {
|
||||
"title": "Unsupported system - CGroup version",
|
||||
"description": "System is unsupported because the wrong version of Docker CGroup is in use. Use the link to learn the correct version and how to fix this."
|
||||
},
|
||||
"unsupported_connectivity_check": {
|
||||
"title": "Unsupported system - Connectivity check disabled",
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"title": "Unsupported system - Content-trust check disabled",
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"title": "Unsupported system - D-Bus issues",
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_dns_server": {
|
||||
"title": "Unsupported system - DNS server issues",
|
||||
"description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_docker_configuration": {
|
||||
"title": "Unsupported system - Docker misconfigured",
|
||||
"description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_docker_version": {
|
||||
"title": "Unsupported system - Docker version",
|
||||
"description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this."
|
||||
},
|
||||
"unsupported_job_conditions": {
|
||||
"title": "Unsupported system - Protections disabled",
|
||||
"description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_lxc": {
|
||||
"title": "Unsupported system - LXC detected",
|
||||
"description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_network_manager": {
|
||||
"title": "Unsupported system - Network Manager issues",
|
||||
"description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_os": {
|
||||
"title": "Unsupported system - Operating System",
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this."
|
||||
},
|
||||
"unsupported_os_agent": {
|
||||
"title": "Unsupported system - OS-Agent issues",
|
||||
"description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_restart_policy": {
|
||||
"title": "Unsupported system - Container restart policy",
|
||||
"description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_software": {
|
||||
"title": "Unsupported system - Unsupported software",
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"title": "Unsupported system - Supervisor source modifications",
|
||||
"description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"title": "Unsupported system - Supervisor version",
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd": {
|
||||
"title": "Unsupported system - Systemd issues",
|
||||
"description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd_journal": {
|
||||
"title": "Unsupported system - Systemd Journal issues",
|
||||
"description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd_resolved": {
|
||||
"title": "Unsupported system - Systemd-Resolved issues",
|
||||
"description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "El sistema no \u00e9s saludable a causa de '{reason}'. Clica l'enlla\u00e7 per obtenir m\u00e9s informaci\u00f3 sobre qu\u00e8 falla aix\u00f2 i com solucionar-ho.",
|
||||
"title": "Sistema no saludable - {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "El sistema no \u00e9s compatible a causa de '{reason}'. Clica l'enlla\u00e7 per obtenir m\u00e9s informaci\u00f3 sobre qu\u00e8 significa aix\u00f2 i com tornar a un sistema compatible.",
|
||||
"title": "Sistema no compatible - {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "Versi\u00f3 de l'agent",
|
||||
|
||||
@@ -1,4 +1,114 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - {reason}"
|
||||
},
|
||||
"unhealthy_docker": {
|
||||
"description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - Docker misconfigured"
|
||||
},
|
||||
"unhealthy_privileged": {
|
||||
"description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - Not privileged"
|
||||
},
|
||||
"unhealthy_setup": {
|
||||
"description": "System is currently because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - Setup failed"
|
||||
},
|
||||
"unhealthy_supervisor": {
|
||||
"description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - Supervisor update failed"
|
||||
},
|
||||
"unhealthy_untrusted": {
|
||||
"description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this.",
|
||||
"title": "Unhealthy system - Untrusted code"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - {reason}"
|
||||
},
|
||||
"unsupported_apparmor": {
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - AppArmor issues"
|
||||
},
|
||||
"unsupported_cgroup_version": {
|
||||
"description": "System is unsupported because the wrong version of Docker CGroup is in use. Use the link to learn the correct version and how to fix this.",
|
||||
"title": "Unsupported system - CGroup version"
|
||||
},
|
||||
"unsupported_connectivity_check": {
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
},
|
||||
"unsupported_dns_server": {
|
||||
"description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - DNS server issues"
|
||||
},
|
||||
"unsupported_docker_configuration": {
|
||||
"description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Docker misconfigured"
|
||||
},
|
||||
"unsupported_docker_version": {
|
||||
"description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this.",
|
||||
"title": "Unsupported system - Docker version"
|
||||
},
|
||||
"unsupported_job_conditions": {
|
||||
"description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Protections disabled"
|
||||
},
|
||||
"unsupported_lxc": {
|
||||
"description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - LXC detected"
|
||||
},
|
||||
"unsupported_network_manager": {
|
||||
"description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Network Manager issues"
|
||||
},
|
||||
"unsupported_os": {
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this.",
|
||||
"title": "Unsupported system - Operating System"
|
||||
},
|
||||
"unsupported_os_agent": {
|
||||
"description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - OS-Agent issues"
|
||||
},
|
||||
"unsupported_restart_policy": {
|
||||
"description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Container restart policy"
|
||||
},
|
||||
"unsupported_software": {
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
},
|
||||
"unsupported_systemd": {
|
||||
"description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Systemd issues"
|
||||
},
|
||||
"unsupported_systemd_journal": {
|
||||
"description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Systemd Journal issues"
|
||||
},
|
||||
"unsupported_systemd_resolved": {
|
||||
"description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this.",
|
||||
"title": "Unsupported system - Systemd-Resolved issues"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "Agent Version",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "Actualmente el sistema no est\u00e1 en buen estado debido a ''{reason}''. Utiliza el enlace para obtener m\u00e1s informaci\u00f3n sobre lo que est\u00e1 mal y c\u00f3mo solucionarlo.",
|
||||
"title": "Sistema en mal estado: {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "El sistema no es compatible debido a ''{reason}''. Utiliza el enlace para obtener m\u00e1s informaci\u00f3n sobre lo que esto significa y c\u00f3mo volver a un sistema compatible.",
|
||||
"title": "Sistema no compatible: {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "Versi\u00f3n del agente",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "S\u00fcsteem ei ole praegu korras '{reason}' t\u00f5ttu. Kasuta linki, et saada rohkem teavet selle kohta, mis on valesti ja kuidas seda parandada.",
|
||||
"title": "Vigane s\u00fcsteem \u2013 {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "S\u00fcsteemi ei toetata '{reason}' t\u00f5ttu. Kasuta linki, et saada lisateavet selle kohta, mida see t\u00e4hendab ja kuidas toetatud s\u00fcsteemi naasta.",
|
||||
"title": "Toetamata s\u00fcsteem \u2013 {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "Agendi versioon",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "A rendszer jelenleg renellenes \u00e1llapotban van '{reason}' miatt. A link seg\u00edts\u00e9g\u00e9vel t\u00f6bbet is megtudhat arr\u00f3l, hogy mi a probl\u00e9ma, \u00e9s hogyan jav\u00edthatja ki.",
|
||||
"title": "Rendellenes \u00e1llapot \u2013 {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "A rendszer nem t\u00e1mogatott a k\u00f6vetkez\u0151 miatt: '{reason}'. A hivatkoz\u00e1s seg\u00edts\u00e9g\u00e9vel t\u00f6bbet megtudhat arr\u00f3l, mit jelent ez, \u00e9s hogyan t\u00e9rhet vissza egy t\u00e1mogatott rendszerhez.",
|
||||
"title": "Nem t\u00e1mogatott rendszer \u2013 {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "\u00dcgyn\u00f6k verzi\u00f3",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "O sistema n\u00e3o est\u00e1 \u00edntegro devido a '{reason}'. Use o link para saber mais sobre o que est\u00e1 errado e como corrigi-lo.",
|
||||
"title": "Sistema insalubre - {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "O sistema n\u00e3o \u00e9 suportado devido a '{reason}'. Use o link para saber mais sobre o que isso significa e como retornar a um sistema compat\u00edvel.",
|
||||
"title": "Sistema n\u00e3o suportado - {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "Vers\u00e3o do Agent",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"issues": {
|
||||
"unhealthy": {
|
||||
"description": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435\u0440\u0430\u0431\u043e\u0442\u043e\u0441\u043f\u043e\u0441\u043e\u0431\u043d\u0430 \u043f\u043e \u043f\u0440\u0438\u0447\u0438\u043d\u0435 '{reason}'. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0437\u043d\u0430\u0442\u044c \u0447\u0442\u043e \u043d\u0435 \u0442\u0430\u043a \u0438 \u043a\u0430\u043a \u044d\u0442\u043e \u0438\u0441\u043f\u0440\u0430\u0432\u0438\u0442\u044c.",
|
||||
"title": "\u041d\u0435\u0440\u0430\u0431\u043e\u0442\u043e\u0441\u043f\u043e\u0441\u043e\u0431\u043d\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 - {reason}"
|
||||
},
|
||||
"unsupported": {
|
||||
"description": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u043f\u043e \u043f\u0440\u0438\u0447\u0438\u043d\u0435 '{reason}'. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0437\u043d\u0430\u0442\u044c \u0447\u0442\u043e \u044d\u0442\u043e \u0437\u043d\u0430\u0447\u0438\u0442 \u0438 \u043a\u0430\u043a \u0432\u0435\u0440\u043d\u0443\u0442\u044c\u0441\u044f \u043a \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435.",
|
||||
"title": "\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 - {reason}"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"agent_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u0430\u0433\u0435\u043d\u0442\u0430",
|
||||
|
||||
@@ -10,8 +10,10 @@ import os
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp import web
|
||||
from pyhap.characteristic import Characteristic
|
||||
from pyhap.const import STANDALONE_AID
|
||||
from pyhap.loader import get_loader
|
||||
from pyhap.service import Service
|
||||
import voluptuous as vol
|
||||
from zeroconf.asyncio import AsyncZeroconf
|
||||
|
||||
@@ -21,6 +23,9 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.components.device_automation.trigger import (
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
|
||||
from homeassistant.components.network import MDNS_TARGET_IP
|
||||
@@ -74,13 +79,7 @@ from . import ( # noqa: F401
|
||||
type_switches,
|
||||
type_thermostats,
|
||||
)
|
||||
from .accessories import (
|
||||
HomeAccessory,
|
||||
HomeBridge,
|
||||
HomeDriver,
|
||||
HomeIIDManager,
|
||||
get_accessory,
|
||||
)
|
||||
from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory
|
||||
from .aidmanager import AccessoryAidStorage
|
||||
from .const import (
|
||||
ATTR_INTEGRATION,
|
||||
@@ -139,7 +138,7 @@ STATUS_WAIT = 3
|
||||
PORT_CLEANUP_CHECK_INTERVAL_SECS = 1
|
||||
|
||||
_HOMEKIT_CONFIG_UPDATE_TIME = (
|
||||
5 # number of seconds to wait for homekit to see the c# change
|
||||
10 # number of seconds to wait for homekit to see the c# change
|
||||
)
|
||||
|
||||
|
||||
@@ -529,6 +528,7 @@ class HomeKit:
|
||||
self.status = STATUS_READY
|
||||
self.driver: HomeDriver | None = None
|
||||
self.bridge: HomeBridge | None = None
|
||||
self._reset_lock = asyncio.Lock()
|
||||
|
||||
def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None:
|
||||
"""Set up bridge and accessory driver."""
|
||||
@@ -548,7 +548,7 @@ class HomeKit:
|
||||
async_zeroconf_instance=async_zeroconf_instance,
|
||||
zeroconf_server=f"{uuid}-hap.local.",
|
||||
loader=get_loader(),
|
||||
iid_manager=HomeIIDManager(self.iid_storage),
|
||||
iid_storage=self.iid_storage,
|
||||
)
|
||||
|
||||
# If we do not load the mac address will be wrong
|
||||
@@ -558,21 +558,24 @@ class HomeKit:
|
||||
|
||||
async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None:
|
||||
"""Reset the accessory to load the latest configuration."""
|
||||
if not self.bridge:
|
||||
await self.async_reset_accessories_in_accessory_mode(entity_ids)
|
||||
return
|
||||
await self.async_reset_accessories_in_bridge_mode(entity_ids)
|
||||
async with self._reset_lock:
|
||||
if not self.bridge:
|
||||
await self.async_reset_accessories_in_accessory_mode(entity_ids)
|
||||
return
|
||||
await self.async_reset_accessories_in_bridge_mode(entity_ids)
|
||||
|
||||
async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None:
|
||||
"""Shutdown an accessory."""
|
||||
assert self.driver is not None
|
||||
await accessory.stop()
|
||||
# Deallocate the IIDs for the accessory
|
||||
iid_manager = self.driver.iid_manager
|
||||
for service in accessory.services:
|
||||
iid_manager.remove_iid(iid_manager.remove_obj(service))
|
||||
for char in service.characteristics:
|
||||
iid_manager.remove_iid(iid_manager.remove_obj(char))
|
||||
iid_manager = accessory.iid_manager
|
||||
services: list[Service] = accessory.services
|
||||
for service in services:
|
||||
iid_manager.remove_obj(service)
|
||||
characteristics: list[Characteristic] = service.characteristics
|
||||
for char in characteristics:
|
||||
iid_manager.remove_obj(char)
|
||||
|
||||
async def async_reset_accessories_in_accessory_mode(
|
||||
self, entity_ids: Iterable[str]
|
||||
@@ -581,7 +584,6 @@ class HomeKit:
|
||||
assert self.driver is not None
|
||||
|
||||
acc = cast(HomeAccessory, self.driver.accessory)
|
||||
await self._async_shutdown_accessory(acc)
|
||||
if acc.entity_id not in entity_ids:
|
||||
return
|
||||
if not (state := self.hass.states.get(acc.entity_id)):
|
||||
@@ -589,6 +591,7 @@ class HomeKit:
|
||||
"The underlying entity %s disappeared during reset", acc.entity_id
|
||||
)
|
||||
return
|
||||
await self._async_shutdown_accessory(acc)
|
||||
if new_acc := self._async_create_single_accessory([state]):
|
||||
self.driver.accessory = new_acc
|
||||
self.hass.async_add_job(new_acc.run)
|
||||
@@ -906,29 +909,47 @@ class HomeKit:
|
||||
self.bridge = HomeBridge(self.hass, self.driver, self._name)
|
||||
for state in entity_states:
|
||||
self.add_bridge_accessory(state)
|
||||
dev_reg = device_registry.async_get(self.hass)
|
||||
if self._devices:
|
||||
valid_device_ids = []
|
||||
for device_id in self._devices:
|
||||
if not dev_reg.async_get(device_id):
|
||||
_LOGGER.warning(
|
||||
"HomeKit %s cannot add device %s because it is missing from the device registry",
|
||||
self._name,
|
||||
device_id,
|
||||
)
|
||||
else:
|
||||
valid_device_ids.append(device_id)
|
||||
for device_id, device_triggers in (
|
||||
await device_automation.async_get_device_automations(
|
||||
self.hass,
|
||||
device_automation.DeviceAutomationType.TRIGGER,
|
||||
valid_device_ids,
|
||||
)
|
||||
).items():
|
||||
if device := dev_reg.async_get(device_id):
|
||||
self.add_bridge_triggers_accessory(device, device_triggers)
|
||||
await self._async_add_trigger_accessories()
|
||||
return self.bridge
|
||||
|
||||
async def _async_add_trigger_accessories(self) -> None:
|
||||
"""Add devices with triggers to the bridge."""
|
||||
dev_reg = device_registry.async_get(self.hass)
|
||||
valid_device_ids = []
|
||||
for device_id in self._devices:
|
||||
if not dev_reg.async_get(device_id):
|
||||
_LOGGER.warning(
|
||||
"HomeKit %s cannot add device %s because it is missing from the device registry",
|
||||
self._name,
|
||||
device_id,
|
||||
)
|
||||
else:
|
||||
valid_device_ids.append(device_id)
|
||||
for device_id, device_triggers in (
|
||||
await device_automation.async_get_device_automations(
|
||||
self.hass,
|
||||
device_automation.DeviceAutomationType.TRIGGER,
|
||||
valid_device_ids,
|
||||
)
|
||||
).items():
|
||||
device = dev_reg.async_get(device_id)
|
||||
assert device is not None
|
||||
valid_device_triggers: list[dict[str, Any]] = []
|
||||
for trigger in device_triggers:
|
||||
try:
|
||||
await async_validate_trigger_config(self.hass, trigger)
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.debug(
|
||||
"%s: cannot add unsupported trigger %s because it requires additional inputs which are not supported by HomeKit: %s",
|
||||
self._name,
|
||||
trigger,
|
||||
ex,
|
||||
)
|
||||
continue
|
||||
valid_device_triggers.append(trigger)
|
||||
self.add_bridge_triggers_accessory(device, valid_device_triggers)
|
||||
|
||||
async def _async_create_accessories(self) -> bool:
|
||||
"""Create the accessories."""
|
||||
assert self.driver is not None
|
||||
|
||||
@@ -270,7 +270,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
|
||||
driver=driver,
|
||||
display_name=cleanup_name_for_homekit(name),
|
||||
aid=aid,
|
||||
iid_manager=driver.iid_manager,
|
||||
iid_manager=HomeIIDManager(driver.iid_storage),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -570,7 +570,7 @@ class HomeBridge(Bridge): # type: ignore[misc]
|
||||
|
||||
def __init__(self, hass: HomeAssistant, driver: HomeDriver, name: str) -> None:
|
||||
"""Initialize a Bridge object."""
|
||||
super().__init__(driver, name, iid_manager=driver.iid_manager)
|
||||
super().__init__(driver, name, iid_manager=HomeIIDManager(driver.iid_storage))
|
||||
self.set_info_service(
|
||||
firmware_revision=format_version(__version__),
|
||||
manufacturer=MANUFACTURER,
|
||||
@@ -603,7 +603,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
|
||||
entry_id: str,
|
||||
bridge_name: str,
|
||||
entry_title: str,
|
||||
iid_manager: HomeIIDManager,
|
||||
iid_storage: AccessoryIIDStorage,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a AccessoryDriver object."""
|
||||
@@ -612,7 +612,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
|
||||
self._entry_id = entry_id
|
||||
self._bridge_name = bridge_name
|
||||
self._entry_title = entry_title
|
||||
self.iid_manager = iid_manager
|
||||
self.iid_storage = iid_storage
|
||||
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
def pair(
|
||||
@@ -653,7 +653,7 @@ class HomeIIDManager(IIDManager): # type: ignore[misc]
|
||||
"""Get IID for object."""
|
||||
aid = obj.broker.aid
|
||||
if isinstance(obj, Characteristic):
|
||||
service = obj.service
|
||||
service: Service = obj.service
|
||||
iid = self._iid_storage.get_or_allocate_iid(
|
||||
aid, service.type_id, service.unique_id, obj.type_id, obj.unique_id
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ async def async_get_config_entry_diagnostics(
|
||||
"options": dict(entry.options),
|
||||
},
|
||||
}
|
||||
if homekit.iid_storage:
|
||||
data["iid_storage"] = homekit.iid_storage.allocations
|
||||
if not homekit.driver: # not started yet or startup failed
|
||||
return data
|
||||
driver: AccessoryDriver = homekit.driver
|
||||
@@ -65,13 +67,16 @@ def _get_accessory_diagnostics(
|
||||
hass: HomeAssistant, accessory: HomeAccessory
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for an accessory."""
|
||||
return {
|
||||
entity_state = None
|
||||
if accessory.entity_id:
|
||||
entity_state = hass.states.get(accessory.entity_id)
|
||||
data = {
|
||||
"aid": accessory.aid,
|
||||
"config": accessory.config,
|
||||
"category": accessory.category,
|
||||
"name": accessory.display_name,
|
||||
"entity_id": accessory.entity_id,
|
||||
"entity_state": async_redact_data(
|
||||
hass.states.get(accessory.entity_id), TO_REDACT
|
||||
),
|
||||
}
|
||||
if entity_state:
|
||||
data["entity_state"] = async_redact_data(entity_state, TO_REDACT)
|
||||
return data
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.storage import Store
|
||||
|
||||
from .util import get_iid_storage_filename_for_entry_id
|
||||
|
||||
IID_MANAGER_STORAGE_VERSION = 1
|
||||
IID_MANAGER_STORAGE_VERSION = 2
|
||||
IID_MANAGER_SAVE_DELAY = 2
|
||||
|
||||
ALLOCATIONS_KEY = "allocations"
|
||||
@@ -26,6 +26,40 @@ IID_MIN = 1
|
||||
IID_MAX = 18446744073709551615
|
||||
|
||||
|
||||
ACCESSORY_INFORMATION_SERVICE = "3E"
|
||||
|
||||
|
||||
class IIDStorage(Store):
|
||||
"""Storage class for IIDManager."""
|
||||
|
||||
async def _async_migrate_func(
|
||||
self,
|
||||
old_major_version: int,
|
||||
old_minor_version: int,
|
||||
old_data: dict,
|
||||
):
|
||||
"""Migrate to the new version."""
|
||||
if old_major_version == 1:
|
||||
# Convert v1 to v2 format which uses a unique iid set per accessory
|
||||
# instead of per pairing since we need the ACCESSORY_INFORMATION_SERVICE
|
||||
# to always have iid 1 for each bridged accessory as well as the bridge
|
||||
old_allocations: dict[str, int] = old_data.pop(ALLOCATIONS_KEY, {})
|
||||
new_allocation: dict[str, dict[str, int]] = {}
|
||||
old_data[ALLOCATIONS_KEY] = new_allocation
|
||||
for allocation_key, iid in old_allocations.items():
|
||||
aid_str, new_allocation_key = allocation_key.split("_", 1)
|
||||
service_type, _, char_type, *_ = new_allocation_key.split("_")
|
||||
accessory_allocation = new_allocation.setdefault(aid_str, {})
|
||||
if service_type == ACCESSORY_INFORMATION_SERVICE and not char_type:
|
||||
accessory_allocation[new_allocation_key] = 1
|
||||
elif iid != 1:
|
||||
accessory_allocation[new_allocation_key] = iid
|
||||
|
||||
return old_data
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AccessoryIIDStorage:
|
||||
"""
|
||||
Provide stable allocation of IIDs for the lifetime of an accessory.
|
||||
@@ -37,15 +71,15 @@ class AccessoryIIDStorage:
|
||||
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Create a new iid store."""
|
||||
self.hass = hass
|
||||
self.allocations: dict[str, int] = {}
|
||||
self.allocated_iids: list[int] = []
|
||||
self.allocations: dict[str, dict[str, int]] = {}
|
||||
self.allocated_iids: dict[str, list[int]] = {}
|
||||
self.entry_id = entry_id
|
||||
self.store: Store | None = None
|
||||
self.store: IIDStorage | None = None
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Load the latest IID data."""
|
||||
iid_store = get_iid_storage_filename_for_entry_id(self.entry_id)
|
||||
self.store = Store(self.hass, IID_MANAGER_STORAGE_VERSION, iid_store)
|
||||
self.store = IIDStorage(self.hass, IID_MANAGER_STORAGE_VERSION, iid_store)
|
||||
|
||||
if not (raw_storage := await self.store.async_load()):
|
||||
# There is no data about iid allocations yet
|
||||
@@ -53,7 +87,8 @@ class AccessoryIIDStorage:
|
||||
|
||||
assert isinstance(raw_storage, dict)
|
||||
self.allocations = raw_storage.get(ALLOCATIONS_KEY, {})
|
||||
self.allocated_iids = sorted(self.allocations.values())
|
||||
for aid_str, allocations in self.allocations.items():
|
||||
self.allocated_iids[aid_str] = sorted(allocations.values())
|
||||
|
||||
def get_or_allocate_iid(
|
||||
self,
|
||||
@@ -68,16 +103,25 @@ class AccessoryIIDStorage:
|
||||
char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None
|
||||
# Allocation key must be a string since we are saving it to JSON
|
||||
allocation_key = (
|
||||
f'{aid}_{service_hap_type}_{service_unique_id or ""}_'
|
||||
f'{service_hap_type}_{service_unique_id or ""}_'
|
||||
f'{char_hap_type or ""}_{char_unique_id or ""}'
|
||||
)
|
||||
if allocation_key in self.allocations:
|
||||
return self.allocations[allocation_key]
|
||||
next_iid = self.allocated_iids[-1] + 1 if self.allocated_iids else 1
|
||||
self.allocations[allocation_key] = next_iid
|
||||
self.allocated_iids.append(next_iid)
|
||||
# AID must be a string since JSON keys cannot be int
|
||||
aid_str = str(aid)
|
||||
accessory_allocation = self.allocations.setdefault(aid_str, {})
|
||||
accessory_allocated_iids = self.allocated_iids.setdefault(aid_str, [1])
|
||||
if service_hap_type == ACCESSORY_INFORMATION_SERVICE and char_uuid is None:
|
||||
return 1
|
||||
if allocation_key in accessory_allocation:
|
||||
return accessory_allocation[allocation_key]
|
||||
if accessory_allocated_iids:
|
||||
allocated_iid = accessory_allocated_iids[-1] + 1
|
||||
else:
|
||||
allocated_iid = 2
|
||||
accessory_allocation[allocation_key] = allocated_iid
|
||||
accessory_allocated_iids.append(allocated_iid)
|
||||
self._async_schedule_save()
|
||||
return next_iid
|
||||
return allocated_iid
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
@@ -91,6 +135,6 @@ class AccessoryIIDStorage:
|
||||
return await self.store.async_save(self._data_to_save())
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> dict[str, dict[str, int]]:
|
||||
def _data_to_save(self) -> dict[str, dict[str, dict[str, int]]]:
|
||||
"""Return data of entity map to store in a file."""
|
||||
return {ALLOCATIONS_KEY: self.allocations}
|
||||
|
||||
@@ -7,9 +7,11 @@ from typing import Any
|
||||
from pyhap.const import CATEGORY_SENSOR
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, Context
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.trigger import async_initialize_triggers
|
||||
|
||||
from .accessories import TYPES, HomeAccessory
|
||||
from .aidmanager import get_system_unique_id
|
||||
from .const import (
|
||||
CHAR_NAME,
|
||||
CHAR_PROGRAMMABLE_SWITCH_EVENT,
|
||||
@@ -18,6 +20,7 @@ from .const import (
|
||||
SERV_SERVICE_LABEL,
|
||||
SERV_STATELESS_PROGRAMMABLE_SWITCH,
|
||||
)
|
||||
from .util import cleanup_name_for_homekit
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,13 +42,22 @@ class DeviceTriggerAccessory(HomeAccessory):
|
||||
self._remove_triggers: CALLBACK_TYPE | None = None
|
||||
self.triggers = []
|
||||
assert device_triggers is not None
|
||||
ent_reg = entity_registry.async_get(self.hass)
|
||||
for idx, trigger in enumerate(device_triggers):
|
||||
type_ = trigger["type"]
|
||||
subtype = trigger.get("subtype")
|
||||
type_: str = trigger["type"]
|
||||
subtype: str | None = trigger.get("subtype")
|
||||
unique_id = f'{type_}-{subtype or ""}'
|
||||
trigger_name = (
|
||||
f"{type_.title()} {subtype.title()}" if subtype else type_.title()
|
||||
)
|
||||
if (entity_id := trigger.get("entity_id")) and (
|
||||
entry := ent_reg.async_get(entity_id)
|
||||
):
|
||||
unique_id += f"-entity_unique_id:{get_system_unique_id(entry)}"
|
||||
trigger_name_parts = []
|
||||
if entity_id and (state := self.hass.states.get(entity_id)):
|
||||
trigger_name_parts.append(state.name)
|
||||
trigger_name_parts.append(type_.replace("_", " ").title())
|
||||
if subtype:
|
||||
trigger_name_parts.append(subtype.replace("_", " ").title())
|
||||
trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts))
|
||||
serv_stateless_switch = self.add_preload_service(
|
||||
SERV_STATELESS_PROGRAMMABLE_SWITCH,
|
||||
[CHAR_NAME, CHAR_SERVICE_LABEL_INDEX],
|
||||
|
||||
@@ -209,6 +209,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity):
|
||||
)
|
||||
await self.async_put_characteristics(
|
||||
{
|
||||
CharacteristicsTypes.ACTIVE: ActivationStateValues.ACTIVE,
|
||||
CharacteristicsTypes.TARGET_HEATER_COOLER_STATE: TARGET_HEATER_COOLER_STATE_HASS_TO_HOMEKIT[
|
||||
hvac_mode
|
||||
],
|
||||
|
||||
@@ -15,7 +15,7 @@ from aiohomekit.controller.abstract import (
|
||||
from aiohomekit.exceptions import AuthenticationError
|
||||
from aiohomekit.model.categories import Categories
|
||||
from aiohomekit.model.status_flags import StatusFlags
|
||||
from aiohomekit.utils import domain_supported, domain_to_name
|
||||
from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -577,6 +577,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
pairing.id,
|
||||
accessories_state.config_num,
|
||||
accessories_state.accessories.serialize(),
|
||||
serialize_broadcast_key(accessories_state.broadcast_key),
|
||||
)
|
||||
|
||||
return self.async_create_entry(title=name, data=pairing_data)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==2.2.7"],
|
||||
"requirements": ["aiohomekit==2.2.19"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
|
||||
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
|
||||
"dependencies": ["bluetooth", "zeroconf"],
|
||||
|
||||
@@ -183,7 +183,7 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = {
|
||||
key=CharacteristicsTypes.VENDOR_EVE_ENERGY_KW_HOUR,
|
||||
name="Energy kWh",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_EVE_ENERGY_VOLTAGE: HomeKitSensorEntityDescription(
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any
|
||||
|
||||
from aiohomekit.characteristic_cache import Pairing, StorageLayout
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -16,19 +18,6 @@ ENTITY_MAP_SAVE_DELAY = 10
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pairing(TypedDict):
|
||||
"""A versioned map of entity metadata as presented by aiohomekit."""
|
||||
|
||||
config_num: int
|
||||
accessories: list[Any]
|
||||
|
||||
|
||||
class StorageLayout(TypedDict):
|
||||
"""Cached pairing metadata needed by aiohomekit."""
|
||||
|
||||
pairings: dict[str, Pairing]
|
||||
|
||||
|
||||
class EntityMapStorage:
|
||||
"""
|
||||
Holds a cache of entity structure data from a paired HomeKit device.
|
||||
@@ -67,11 +56,17 @@ class EntityMapStorage:
|
||||
|
||||
@callback
|
||||
def async_create_or_update_map(
|
||||
self, homekit_id: str, config_num: int, accessories: list[Any]
|
||||
self,
|
||||
homekit_id: str,
|
||||
config_num: int,
|
||||
accessories: list[Any],
|
||||
broadcast_key: str | None = None,
|
||||
) -> Pairing:
|
||||
"""Create a new pairing cache."""
|
||||
_LOGGER.debug("Creating or updating entity map for %s", homekit_id)
|
||||
data = Pairing(config_num=config_num, accessories=accessories)
|
||||
data = Pairing(
|
||||
config_num=config_num, accessories=accessories, broadcast_key=broadcast_key
|
||||
)
|
||||
self.storage_data[homekit_id] = data
|
||||
self._async_schedule_save()
|
||||
return data
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
|
||||
"requirements": [
|
||||
"huawei-lte-api==1.6.3",
|
||||
"huawei-lte-api==1.6.7",
|
||||
"stringcase==1.2.0",
|
||||
"url-normalize==1.4.3"
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Huisbaasje",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huisbaasje",
|
||||
"requirements": ["energyflip-client==0.2.1"],
|
||||
"requirements": ["energyflip-client==0.2.2"],
|
||||
"codeowners": ["@dennisschroer"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["huisbaasje"]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/iaqualink/",
|
||||
"codeowners": ["@flz"],
|
||||
"requirements": ["iaqualink==0.5.0"],
|
||||
"requirements": ["iaqualink==0.5.0", "h2==4.1.0"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["iaqualink"]
|
||||
}
|
||||
|
||||
@@ -396,7 +396,11 @@ class IBeaconCoordinator:
|
||||
)
|
||||
continue
|
||||
|
||||
if service_info.rssi != ibeacon_advertisement.rssi:
|
||||
if (
|
||||
service_info.rssi != ibeacon_advertisement.rssi
|
||||
or service_info.source != ibeacon_advertisement.source
|
||||
):
|
||||
ibeacon_advertisement.source = service_info.source
|
||||
ibeacon_advertisement.update_rssi(service_info.rssi)
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Internet Printing Protocol (IPP)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ipp",
|
||||
"integration_type": "device",
|
||||
"requirements": ["pyipp==0.12.0"],
|
||||
"requirements": ["pyipp==0.12.1"],
|
||||
"codeowners": ["@ctalkington"],
|
||||
"config_flow": true,
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "KNX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/knx",
|
||||
"requirements": ["xknx==1.2.0"],
|
||||
"requirements": ["xknx==1.2.1"],
|
||||
"codeowners": ["@Julius2342", "@farmio", "@marvin-w"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "lidarr",
|
||||
"name": "Lidarr",
|
||||
"documentation": "https://www.home-assistant.io/integrations/lidarr",
|
||||
"requirements": ["aiopyarr==22.10.0"],
|
||||
"requirements": ["aiopyarr==22.11.0"],
|
||||
"codeowners": ["@tkdrob"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
|
||||
DOMAIN = "life360"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
ATTRIBUTION = "Data provided by life360.com"
|
||||
COMM_TIMEOUT = 10
|
||||
COMM_MAX_RETRIES = 3
|
||||
COMM_TIMEOUT = ClientTimeout(sock_connect=15, total=60)
|
||||
SPEED_FACTOR_MPH = 2.25
|
||||
SPEED_DIGITS = 1
|
||||
UPDATE_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.util.unit_conversion import DistanceConverter
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import (
|
||||
COMM_MAX_RETRIES,
|
||||
COMM_TIMEOUT,
|
||||
CONF_AUTHORIZATION,
|
||||
DOMAIN,
|
||||
@@ -106,6 +107,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]):
|
||||
self._api = Life360(
|
||||
session=async_get_clientsession(hass),
|
||||
timeout=COMM_TIMEOUT,
|
||||
max_retries=COMM_MAX_RETRIES,
|
||||
authorization=entry.data[CONF_AUTHORIZATION],
|
||||
)
|
||||
self._missing_loc_reason = hass.data[DOMAIN].missing_loc_reason
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/life360",
|
||||
"codeowners": ["@pnbruckner"],
|
||||
"requirements": ["life360==5.1.1"],
|
||||
"requirements": ["life360==5.3.0"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["life360"]
|
||||
}
|
||||
|
||||
@@ -14,8 +14,12 @@ from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_BRIGHTNESS_PCT,
|
||||
ATTR_COLOR_NAME,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_XY_COLOR,
|
||||
)
|
||||
@@ -24,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from .const import DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT
|
||||
from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT
|
||||
|
||||
FIX_MAC_FW = AwesomeVersion("3.70")
|
||||
|
||||
@@ -80,6 +84,17 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
|
||||
"""
|
||||
hue, saturation, brightness, kelvin = [None] * 4
|
||||
|
||||
if (color_name := kwargs.get(ATTR_COLOR_NAME)) is not None:
|
||||
try:
|
||||
hue, saturation = color_util.color_RGB_to_hs(
|
||||
*color_util.color_name_to_rgb(color_name)
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Got unknown color %s, falling back to neutral white", color_name
|
||||
)
|
||||
hue, saturation = (0, 0)
|
||||
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
hue, saturation = kwargs[ATTR_HS_COLOR]
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
@@ -93,6 +108,19 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
|
||||
saturation = int(saturation / 100 * 65535)
|
||||
kelvin = 3500
|
||||
|
||||
if ATTR_KELVIN in kwargs:
|
||||
_LOGGER.warning(
|
||||
"The 'kelvin' parameter is deprecated. Please use 'color_temp_kelvin' for all service calls"
|
||||
)
|
||||
kelvin = kwargs.pop(ATTR_KELVIN)
|
||||
saturation = 0
|
||||
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
kelvin = color_util.color_temperature_mired_to_kelvin(
|
||||
kwargs.pop(ATTR_COLOR_TEMP)
|
||||
)
|
||||
saturation = 0
|
||||
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
kelvin = kwargs.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
saturation = 0
|
||||
@@ -100,6 +128,9 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
|
||||
|
||||
if ATTR_BRIGHTNESS_PCT in kwargs:
|
||||
brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100))
|
||||
|
||||
hsbk = [hue, saturation, brightness, kelvin]
|
||||
return None if hsbk == [None] * 4 else hsbk
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Litter-Robot",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
|
||||
"requirements": ["pylitterbot==2022.10.2"],
|
||||
"requirements": ["pylitterbot==2022.11.0"],
|
||||
"codeowners": ["@natekspencer", "@tkdrob"],
|
||||
"dhcp": [{ "hostname": "litter-robot4" }],
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -375,4 +375,3 @@ async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> No
|
||||
hubs = hass.data[DOMAIN]
|
||||
for name in hubs:
|
||||
await hubs[name].async_close()
|
||||
del hass.data[DOMAIN]
|
||||
|
||||
@@ -132,6 +132,12 @@ async def async_modbus_setup(
|
||||
|
||||
await async_setup_reload_service(hass, DOMAIN, [DOMAIN])
|
||||
|
||||
if DOMAIN in hass.data and config[DOMAIN] == []:
|
||||
hubs = hass.data[DOMAIN]
|
||||
for name in hubs:
|
||||
if not await hubs[name].async_setup():
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN] = hub_collect = {}
|
||||
for conf_hub in config[DOMAIN]:
|
||||
my_hub = ModbusHub(hass, conf_hub)
|
||||
|
||||
@@ -52,6 +52,7 @@ ABBREVIATIONS = {
|
||||
"e": "encoding",
|
||||
"en": "enabled_by_default",
|
||||
"ent_cat": "entity_category",
|
||||
"ent_pic": "entity_picture",
|
||||
"err_t": "error_topic",
|
||||
"err_tpl": "error_template",
|
||||
"fanspd_t": "fan_speed_topic",
|
||||
@@ -169,6 +170,8 @@ ABBREVIATIONS = {
|
||||
"pr_mode_val_tpl": "preset_mode_value_template",
|
||||
"pr_modes": "preset_modes",
|
||||
"r_tpl": "red_template",
|
||||
"rel_s": "release_summary",
|
||||
"rel_u": "release_url",
|
||||
"ret": "retain",
|
||||
"rgb_cmd_tpl": "rgb_command_template",
|
||||
"rgb_cmd_t": "rgb_command_topic",
|
||||
@@ -242,6 +245,7 @@ ABBREVIATIONS = {
|
||||
"tilt_opt": "tilt_optimistic",
|
||||
"tilt_status_t": "tilt_status_topic",
|
||||
"tilt_status_tpl": "tilt_status_template",
|
||||
"tit": "title",
|
||||
"t": "topic",
|
||||
"uniq_id": "unique_id",
|
||||
"unit_of_meas": "unit_of_measurement",
|
||||
|
||||
@@ -271,8 +271,8 @@ class MqttSensor(MqttEntity, RestoreSensor):
|
||||
)
|
||||
elif self.device_class == SensorDeviceClass.DATE:
|
||||
payload = payload.date()
|
||||
if payload != "":
|
||||
self._state = payload
|
||||
|
||||
self._state = payload
|
||||
|
||||
def _update_last_reset(msg):
|
||||
payload = self._last_reset_template(msg.payload)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLAT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
@@ -30,6 +31,7 @@ from .const import (
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
PAYLOAD_EMPTY_JSON,
|
||||
)
|
||||
from .debug_info import log_messages
|
||||
from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
|
||||
@@ -40,20 +42,28 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "MQTT Update"
|
||||
|
||||
CONF_ENTITY_PICTURE = "entity_picture"
|
||||
CONF_LATEST_VERSION_TEMPLATE = "latest_version_template"
|
||||
CONF_LATEST_VERSION_TOPIC = "latest_version_topic"
|
||||
CONF_PAYLOAD_INSTALL = "payload_install"
|
||||
CONF_RELEASE_SUMMARY = "release_summary"
|
||||
CONF_RELEASE_URL = "release_url"
|
||||
CONF_TITLE = "title"
|
||||
|
||||
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_PICTURE): cv.string,
|
||||
vol.Optional(CONF_LATEST_VERSION_TEMPLATE): cv.template,
|
||||
vol.Required(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_INSTALL): cv.string,
|
||||
vol.Optional(CONF_RELEASE_SUMMARY): cv.string,
|
||||
vol.Optional(CONF_RELEASE_URL): cv.string,
|
||||
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
vol.Optional(CONF_TITLE): cv.string,
|
||||
},
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
@@ -99,10 +109,22 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
|
||||
"""Initialize the MQTT update."""
|
||||
self._config = config
|
||||
self._attr_device_class = self._config.get(CONF_DEVICE_CLASS)
|
||||
self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY)
|
||||
self._attr_release_url = self._config.get(CONF_RELEASE_URL)
|
||||
self._attr_title = self._config.get(CONF_TITLE)
|
||||
self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE)
|
||||
|
||||
UpdateEntity.__init__(self)
|
||||
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the entity picture to use in the frontend."""
|
||||
if self._entity_picture is not None:
|
||||
return self._entity_picture
|
||||
|
||||
return super().entity_picture
|
||||
|
||||
@staticmethod
|
||||
def config_schema() -> vol.Schema:
|
||||
"""Return the config schema."""
|
||||
@@ -138,15 +160,59 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
def handle_installed_version_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle receiving installed version via MQTT."""
|
||||
installed_version = self._templates[CONF_VALUE_TEMPLATE](msg.payload)
|
||||
def handle_state_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle receiving state message via MQTT."""
|
||||
payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload)
|
||||
|
||||
if isinstance(installed_version, str) and installed_version != "":
|
||||
self._attr_installed_version = installed_version
|
||||
if not payload or payload == PAYLOAD_EMPTY_JSON:
|
||||
_LOGGER.debug(
|
||||
"Ignoring empty payload '%s' after rendering for topic %s",
|
||||
payload,
|
||||
msg.topic,
|
||||
)
|
||||
return
|
||||
|
||||
json_payload = {}
|
||||
try:
|
||||
json_payload = json_loads(payload)
|
||||
_LOGGER.debug(
|
||||
"JSON payload detected after processing payload '%s' on topic %s",
|
||||
json_payload,
|
||||
msg.topic,
|
||||
)
|
||||
except JSON_DECODE_EXCEPTIONS:
|
||||
_LOGGER.debug(
|
||||
"No valid (JSON) payload detected after processing payload '%s' on topic %s",
|
||||
payload,
|
||||
msg.topic,
|
||||
)
|
||||
json_payload["installed_version"] = payload
|
||||
|
||||
if "installed_version" in json_payload:
|
||||
self._attr_installed_version = json_payload["installed_version"]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
add_subscription(topics, CONF_STATE_TOPIC, handle_installed_version_received)
|
||||
if "latest_version" in json_payload:
|
||||
self._attr_latest_version = json_payload["latest_version"]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
if CONF_TITLE in json_payload:
|
||||
self._attr_title = json_payload[CONF_TITLE]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
if CONF_RELEASE_SUMMARY in json_payload:
|
||||
self._attr_release_summary = json_payload[CONF_RELEASE_SUMMARY]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
if CONF_RELEASE_URL in json_payload:
|
||||
self._attr_release_url = json_payload[CONF_RELEASE_URL]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
if CONF_ENTITY_PICTURE in json_payload:
|
||||
self._entity_picture = json_payload[CONF_ENTITY_PICTURE]
|
||||
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
|
||||
|
||||
add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received)
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
|
||||
@@ -56,6 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
try:
|
||||
await nam.async_check_credentials()
|
||||
except ApiError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except AuthFailed as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
|
||||
@@ -271,7 +271,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
if CONF_WEBHOOK_ID in entry.data:
|
||||
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
await data[entry.entry_id][AUTH].async_dropwebhook()
|
||||
try:
|
||||
await data[entry.entry_id][AUTH].async_dropwebhook()
|
||||
except pyatmo.ApiError:
|
||||
_LOGGER.debug("No webhook to be dropped")
|
||||
_LOGGER.info("Unregister Netatmo webhook")
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -193,17 +193,20 @@ class NetatmoLight(NetatmoBase, LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn light on."""
|
||||
_LOGGER.debug("Turn light '%s' on", self.name)
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
await self._dimmer.async_set_brightness(kwargs[ATTR_BRIGHTNESS])
|
||||
|
||||
else:
|
||||
await self._dimmer.async_on()
|
||||
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn light off."""
|
||||
_LOGGER.debug("Turn light '%s' off", self.name)
|
||||
await self._dimmer.async_off()
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def async_update_callback(self) -> None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "netatmo",
|
||||
"name": "Netatmo",
|
||||
"documentation": "https://www.home-assistant.io/integrations/netatmo",
|
||||
"requirements": ["pyatmo==7.2.0"],
|
||||
"requirements": ["pyatmo==7.4.0"],
|
||||
"after_dependencies": ["cloud", "media_source"],
|
||||
"dependencies": ["application_credentials", "webhook"],
|
||||
"codeowners": ["@cgtobi"],
|
||||
|
||||
@@ -29,7 +29,7 @@ class NetatmoBase(Entity):
|
||||
self._device_name: str = ""
|
||||
self._id: str = ""
|
||||
self._model: str = ""
|
||||
self._config_url: str = ""
|
||||
self._config_url: str | None = None
|
||||
self._attr_name = None
|
||||
self._attr_unique_id = None
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
@@ -77,7 +77,11 @@ class NetatmoSwitch(NetatmoBase, SwitchEntity):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the zone on."""
|
||||
await self._switch.async_on()
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the zone off."""
|
||||
await self._switch.async_off()
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -378,7 +378,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
|
||||
async def async_turn_aux_heat_on(self) -> None:
|
||||
"""Turn Aux Heat on."""
|
||||
self._thermostat.set_emergency_heat(True)
|
||||
await self._thermostat.set_emergency_heat(True)
|
||||
self._signal_thermostat_update()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user