mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 18:55:09 +02:00
Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b849cb58 | |||
| ba5855f5d2 | |||
| 4b04006302 | |||
| 430e03f299 | |||
| 7a2422013c | |||
| c906dc3d0c | |||
| f2fa25d449 | |||
| 0426f9beb6 | |||
| b6f0ca13f9 | |||
| 83e8f4991c | |||
| 3b38208e07 | |||
| 1a15f925a0 | |||
| 10d944eab7 | |||
| 1f873927aa | |||
| fe071ff66b | |||
| e4b79d4f3d | |||
| f6d4d0289e | |||
| 3089f3cc06 | |||
| e24f35473c | |||
| 1da605230d | |||
| fd572d83b7 | |||
| 305d4429ec | |||
| b95a3f5b2d | |||
| 4e986b181b | |||
| 65c074af9a | |||
| 58eae0b815 | |||
| c201c62b3d | |||
| 8b9b21c006 | |||
| b9c00dd82b | |||
| 910b87b847 | |||
| e37459c16b | |||
| c347afe28d | |||
| c8270fcb91 | |||
| ed399a6d14 | |||
| afa01d3d8c | |||
| ba03aaa2fa | |||
| 33f3640f66 | |||
| 46fc47bcdf | |||
| 71ec3c31fa | |||
| 2d54070cab | |||
| 67e4f04f09 | |||
| 78db1e3407 | |||
| 2368a3614d | |||
| 5053392cf2 | |||
| 6ec11460ed | |||
| 975e30c048 | |||
| 7655cb0fc6 | |||
| 7566839e9d | |||
| 7db5e82f58 | |||
| 7e67c53417 | |||
| 89fb856302 | |||
| a2fbd2b1ea | |||
| 231ed34133 | |||
| 6cff433b2e | |||
| eca83fb7b1 | |||
| 2c5adaec5c | |||
| 5d75f1c33b | |||
| d628d2314e | |||
| a9547ec349 | |||
| 2ec637df84 | |||
| 4f50ee5675 | |||
| 0faf96b983 | |||
| c3dacbc601 | |||
| 2659484000 | |||
| 6830ca75f5 | |||
| 38b4184dc3 | |||
| cfde7975d8 | |||
| d7ab696a4c | |||
| 7f7dad7f71 | |||
| 0ed21dbed7 | |||
| d2b37ee28b | |||
| b82c95e77f | |||
| baa61982a1 | |||
| 8ff6de788d | |||
| 640f82642a | |||
| 64ed269f9c | |||
| 2b58ef96eb | |||
| 74ca79ac28 | |||
| afb27bc165 | |||
| 0cbf27f44f | |||
| a5ceafa544 | |||
| cd4d669231 | |||
| cc411d06b5 | |||
| 1329f12d37 | |||
| 3899f5347b | |||
| cf02cfaa7c | |||
| e77c16ea1b | |||
| f1e2f94ee0 | |||
| 3516883b0a | |||
| c8b70b1a38 | |||
| 946625e281 | |||
| f4b7840d5c | |||
| 060f447e4a | |||
| d5bae0a2cf | |||
| f9bef804b1 | |||
| 6de03f4ed6 | |||
| e7f3e5637f | |||
| 80cefc74ec | |||
| 2f33b4b7f9 | |||
| cf52a7a509 | |||
| f5835f849a | |||
| ec5210dca8 | |||
| 422ea1a9b1 | |||
| b6f69f6b99 | |||
| a2a3819241 | |||
| 3ce33b0ac6 | |||
| e507a97d8b | |||
| 5801fdad14 | |||
| 2f4abd6a25 | |||
| 1c045ab715 | |||
| d4ca541a96 | |||
| a07a9dc6c8 | |||
| 6d60b3a23a | |||
| 37bb895b91 | |||
| f87dc917a6 | |||
| 71a15c188e | |||
| 003ecdb867 | |||
| ec7e5e5a75 | |||
| 7587f062e1 | |||
| 11970144e4 | |||
| 70750a6d79 | |||
| a53437315f | |||
| 5c73ad0310 | |||
| 4a04a271ec | |||
| 52c27bdea5 | |||
| 6fdc52c002 | |||
| e560bbc103 | |||
| b8c573685f | |||
| 3764b70b90 | |||
| 5d2de6f82b | |||
| 64d17f44fa | |||
| 6f67d44cfe | |||
| def3befb0e | |||
| 05716ae196 | |||
| c0a864297f | |||
| 04bb84cd03 | |||
| cb55accc3b | |||
| d21c227804 | |||
| 1ebccd9fa2 | |||
| cfbd0f3217 | |||
| 4afb7c0997 | |||
| 105caccc51 | |||
| 6419551117 | |||
| 585bd6616a | |||
| b8dd97cf21 | |||
| 68fc4aed78 | |||
| 7dbb259625 | |||
| 057eac7fb6 | |||
| 31c9cdf742 | |||
| 3147104132 | |||
| d6d0f37b52 | |||
| 75e48745a8 | |||
| 533417778c | |||
| e49fd4ebbd | |||
| 8412b029b1 | |||
| c65de7521f | |||
| 752c17917e | |||
| f643c7ddc6 | |||
| 6f5d4cf991 | |||
| b52466fed1 | |||
| 189534e32b | |||
| 684ae23b18 | |||
| f4d2f65602 | |||
| 65879ff37b | |||
| d902104bee | |||
| 7bad27c412 | |||
| 74a7102cf6 | |||
| e88fb03388 | |||
| 92ce5ed75a | |||
| 466e28eae2 | |||
| d53a8c7df9 | |||
| 8fe26bdf59 | |||
| f75d56c096 | |||
| 7e5942ae51 | |||
| f9ef9963e6 | |||
| cea718528f | |||
| 9c38868bbe | |||
| 62da1c34fb | |||
| 2d3a3bf4fc | |||
| daa60c6d55 | |||
| d45730aa02 | |||
| 05ef944766 | |||
| a51daf48c7 | |||
| 6a789d5af7 | |||
| ad4e218b69 | |||
| 55f576c784 | |||
| c7c3988b11 | |||
| ac825ca36d | |||
| 0b439a25e1 | |||
| 0385e81010 | |||
| 699fed7a3a | |||
| 0eefc8f327 | |||
| 6888d203eb | |||
| 359949adc2 | |||
| afe7d0cbbf | |||
| 2f7b3cb7d9 | |||
| c49ed549db | |||
| 3016198644 | |||
| f0c9156cdb | |||
| f091871aa5 | |||
| d25207180a | |||
| 9ce047b9be | |||
| 488c04fc5b | |||
| 7598fdb8cb | |||
| 49e22072c9 | |||
| c056242390 | |||
| 9cbb14bbde | |||
| 6634c4ce78 | |||
| ae1355666b | |||
| 2d0d202b80 | |||
| 9fd48344f8 | |||
| 7b4ed59861 | |||
| fb8f82542e | |||
| af5583ba76 | |||
| 2a943369d5 | |||
| 29425fd0ac | |||
| 271111fe75 | |||
| 37e9bdd36f | |||
| e1d1bdd377 | |||
| b3a60de487 | |||
| 0cb7ea5584 | |||
| 7bc7694e14 | |||
| c45c949080 | |||
| ec4f64172b |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1421,7 +1421,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1592,7 +1592,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1620,7 +1620,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -609,6 +609,7 @@ homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.velux.*
|
||||
homeassistant.components.victron_gx.*
|
||||
homeassistant.components.vistapool.*
|
||||
homeassistant.components.vivotek.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
|
||||
Generated
+6
-2
@@ -236,8 +236,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/homeassistant/components/blue_current/ @gleeuwen @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @jtodorova23
|
||||
/homeassistant/components/bluemaestro/ @bdraco
|
||||
/tests/components/bluemaestro/ @bdraco
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
@@ -1930,6 +1930,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vistapool/ @fdebrus
|
||||
/tests/components/vistapool/ @fdebrus
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
@@ -2054,6 +2056,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/yi/ @bachya
|
||||
/homeassistant/components/yolink/ @matrixd2
|
||||
/tests/components/yolink/ @matrixd2
|
||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
||||
/tests/components/yoto/ @cdnninja @piitaya
|
||||
/homeassistant/components/youless/ @gjong
|
||||
/tests/components/youless/ @gjong
|
||||
/homeassistant/components/youtube/ @joostlek
|
||||
|
||||
@@ -459,7 +459,6 @@ class AuthManager:
|
||||
token_type: str | None = None,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
@@ -515,7 +514,6 @@ class AuthManager:
|
||||
access_token_expiration,
|
||||
expire_at,
|
||||
credential,
|
||||
scopes,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -211,7 +211,6 @@ class AuthStore:
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
expire_at: float | None = None,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -221,7 +220,6 @@ class AuthStore:
|
||||
"access_token_expiration": access_token_expiration,
|
||||
"expire_at": expire_at,
|
||||
"credential": credential,
|
||||
"scopes": scopes,
|
||||
}
|
||||
if client_name:
|
||||
kwargs["client_name"] = client_name
|
||||
@@ -477,7 +475,6 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
scopes = rt_dict.get("scopes")
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
@@ -496,7 +493,6 @@ class AuthStore:
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
expire_at=rt_dict.get("expire_at"),
|
||||
version=rt_dict.get("version"),
|
||||
scopes=frozenset(scopes) if scopes else None,
|
||||
)
|
||||
if "credential_id" in rt_dict:
|
||||
token.credential = credentials.get(rt_dict["credential_id"])
|
||||
@@ -585,9 +581,6 @@ class AuthStore:
|
||||
if refresh_token.credential
|
||||
else None,
|
||||
"version": refresh_token.version,
|
||||
"scopes": sorted(refresh_token.scopes)
|
||||
if refresh_token.scopes is not None
|
||||
else None,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
|
||||
@@ -129,13 +129,6 @@ class RefreshToken:
|
||||
|
||||
version: str | None = attr.ib(default=__version__)
|
||||
|
||||
# Optional set of websocket-API command scopes. ``None`` means the token
|
||||
# has no scope restriction (the default for normal user/system tokens).
|
||||
# When set, the token may only call commands matching an entry in the
|
||||
# set: a scope ending in ``/`` matches any command whose type starts
|
||||
# with the prefix; otherwise the scope is an exact ``type`` match.
|
||||
scopes: frozenset[str] | None = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
"requirements": ["aioamazondevices==13.8.0"]
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_HOST, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"""Constants for the Altruist integration."""
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_HOST = "host"
|
||||
|
||||
@@ -10,13 +10,12 @@ import logging
|
||||
from altruistclient import AltruistClient, AltruistError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_HOST
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
@@ -24,6 +24,7 @@ from pyatv.interface import (
|
||||
PushListener,
|
||||
PushUpdater,
|
||||
)
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -345,7 +346,10 @@ class AppleTvMediaPlayer(
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
if play_item.path and self._is_feature_available(FeatureName.StreamFile):
|
||||
media_id = str(play_item.path)
|
||||
else:
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
@@ -353,11 +357,16 @@ class AppleTvMediaPlayer(
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl):
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error("Media streaming is not possible with current configuration")
|
||||
_LOGGER.error(
|
||||
"Media streaming is not possible with current configuration for %s",
|
||||
media_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
|
||||
@@ -193,7 +193,11 @@ async def async_setup_entry(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(
|
||||
processor, AranetSensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -9,12 +9,11 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
ATTR_FIRMWARE,
|
||||
ATTR_MODEL,
|
||||
DEFAULT_ADDRESS,
|
||||
DEFAULT_INTEGRATION_TITLE,
|
||||
DOMAIN,
|
||||
|
||||
@@ -19,8 +19,4 @@ DEVICES = "devices"
|
||||
MANUFACTURER = "ABB"
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_FIRMWARE = "firmware"
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
@@ -31,7 +32,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import (
|
||||
ATTR_DEVICE_NAME,
|
||||
ATTR_FIRMWARE,
|
||||
ATTR_MODEL,
|
||||
DEFAULT_DEVICE_NAME,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -19,6 +20,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -27,7 +29,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import AveaConfigEntry
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
@@ -42,6 +44,13 @@ def _normalize_name(name: str | None) -> str | None:
|
||||
return name
|
||||
|
||||
|
||||
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
|
||||
"""Read a device information value from an Avea bulb."""
|
||||
with suppress(*UPDATE_EXCEPTIONS):
|
||||
return _normalize_name(read())
|
||||
return None
|
||||
|
||||
|
||||
def _ha_brightness_to_avea(brightness: int) -> int:
|
||||
"""Convert Home Assistant brightness to Avea brightness."""
|
||||
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
|
||||
@@ -96,7 +105,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities(
|
||||
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,14 +190,42 @@ class AveaLight(LightEntity):
|
||||
"""Representation of an Avea."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
|
||||
def __init__(self, light: avea.Bulb, address: str) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = entry_title
|
||||
self._attr_unique_id = address
|
||||
self._attr_brightness = light.brightness
|
||||
self._last_brightness = 255
|
||||
self._device_info_updated = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, address)},
|
||||
model=MODEL,
|
||||
)
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Fetch device information from the Avea bulb."""
|
||||
device_info = self._attr_device_info
|
||||
assert device_info is not None
|
||||
|
||||
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
|
||||
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
|
||||
firmware_version = _read_device_info_value(self._light.get_fw_version)
|
||||
serial_number = _read_device_info_value(self._light.get_serial_number)
|
||||
|
||||
if manufacturer:
|
||||
device_info["manufacturer"] = manufacturer
|
||||
if hardware_revision:
|
||||
device_info["hw_version"] = hardware_revision
|
||||
if firmware_version:
|
||||
device_info["sw_version"] = firmware_version
|
||||
if serial_number:
|
||||
device_info["serial_number"] = serial_number
|
||||
|
||||
self._device_info_updated = True
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
@@ -214,6 +252,8 @@ class AveaLight(LightEntity):
|
||||
connected = self._light.connect()
|
||||
|
||||
try:
|
||||
if not self._device_info_updated:
|
||||
self._update_device_info()
|
||||
brightness = self._light.get_brightness()
|
||||
rgb_color = self._light.get_rgb()
|
||||
finally:
|
||||
|
||||
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import S3ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,6 +8,7 @@ from botocore.exceptions import ClientError, ConnectionError, ParamValidationErr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -20,7 +21,6 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_AWS_S3_DOCS_URL,
|
||||
|
||||
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||
|
||||
@@ -8,10 +8,11 @@ from aiobotocore.client import AioBaseClient as S3Client
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=6)
|
||||
|
||||
@@ -5,15 +5,10 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_SECRET_ACCESS_KEY, DOMAIN
|
||||
from .coordinator import S3ConfigEntry
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
@@ -50,6 +49,9 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import axis
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
@@ -94,7 +96,8 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
serial = api.vapix.serial_number
|
||||
if (serial := self._get_serial_number(api)) is None:
|
||||
return self.async_abort(reason="no_serial_number")
|
||||
config = {
|
||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
@@ -139,25 +142,15 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _create_entry(self, serial: str) -> ConfigFlowResult:
|
||||
"""Create entry for device.
|
||||
|
||||
Generate a name to be used as a prefix for device entities.
|
||||
Use the discovered device name when available.
|
||||
"""
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
name = model
|
||||
for idx in range(len(same_model) + 1):
|
||||
name = f"{model} {idx}"
|
||||
if name not in same_model:
|
||||
break
|
||||
|
||||
if (title_placeholders := self.context.get("title_placeholders")) is not None:
|
||||
name = title_placeholders[CONF_NAME]
|
||||
else:
|
||||
name = f"{self.config[CONF_MODEL]} - {serial}"
|
||||
self.config[CONF_NAME] = name
|
||||
|
||||
title = f"{model} - {serial}"
|
||||
return self.async_create_entry(title=title, data=self.config)
|
||||
return self.async_create_entry(title=name, data=self.config)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -269,6 +262,19 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
||||
"""Retrieve the device serial number from the Axis API.
|
||||
|
||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
||||
"""
|
||||
vapix = api.vapix
|
||||
if vapix.basic_device_info.initialized:
|
||||
return vapix.basic_device_info["0"].serial_number
|
||||
if vapix.params.property_handler.initialized:
|
||||
return vapix.params.property_handler["0"].system_serial_number
|
||||
return None
|
||||
|
||||
|
||||
class AxisOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Axis device options."""
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import axis
|
||||
from axis.errors import Unauthorized
|
||||
from axis.interfaces.mqtt import mqtt_json_to_event
|
||||
from axis.models.mqtt import ClientState
|
||||
from axis.models.mqtt import ClientState, mqtt_json_to_event
|
||||
from axis.stream_manager import Signal, State
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==71"],
|
||||
"requirements": ["axis==72"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
|
||||
"not_axis_device": "Discovered device not an Axis device",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
|
||||
@@ -32,6 +32,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.cover
|
||||
from blebox_uniapi.cover import BleboxCoverState
|
||||
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -25,6 +25,19 @@ BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"shutter": CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
|
||||
UNIFIED_COVER_TYPE_TO_DEVICE_CLASS = {
|
||||
UnifiedCoverType.AWNING: CoverDeviceClass.AWNING,
|
||||
UnifiedCoverType.BLIND: CoverDeviceClass.BLIND,
|
||||
UnifiedCoverType.CURTAIN: CoverDeviceClass.CURTAIN,
|
||||
UnifiedCoverType.DAMPER: CoverDeviceClass.DAMPER,
|
||||
UnifiedCoverType.DOOR: CoverDeviceClass.DOOR,
|
||||
UnifiedCoverType.GARAGE: CoverDeviceClass.GARAGE,
|
||||
UnifiedCoverType.GATE: CoverDeviceClass.GATE,
|
||||
UnifiedCoverType.SHADE: CoverDeviceClass.SHADE,
|
||||
UnifiedCoverType.SHUTTER: CoverDeviceClass.SHUTTER,
|
||||
UnifiedCoverType.WINDOW: CoverDeviceClass.WINDOW,
|
||||
}
|
||||
|
||||
BLEBOX_TO_HASS_COVER_STATES = {
|
||||
None: None,
|
||||
# all blebox covers
|
||||
@@ -59,7 +72,6 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(feature)
|
||||
self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class]
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -76,6 +88,21 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
| CoverEntityFeature.STOP
|
||||
)
|
||||
|
||||
@property
|
||||
def device_class(self) -> CoverDeviceClass | None:
|
||||
"""Return the device class based on cover type when available."""
|
||||
if (cover_type := self._feature.cover_type) is not None:
|
||||
return UNIFIED_COVER_TYPE_TO_DEVICE_CLASS[cover_type]
|
||||
return BLEBOX_TO_COVER_DEVICE_CLASSES[self._feature.device_class]
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the current cover position."""
|
||||
@@ -118,7 +145,8 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
await self._feature.async_set_tilt_position(0)
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.3"],
|
||||
"requirements": ["blebox-uniapi==2.5.4"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""BleBox update entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from blebox_uniapi.error import ConnectionError as BleBoxConnectionError, Error
|
||||
import blebox_uniapi.update
|
||||
|
||||
from homeassistant.components.update import (
|
||||
UpdateDeviceClass,
|
||||
UpdateEntity,
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
_POLL_INTERVAL_SECONDS: Final = 10
|
||||
_MAX_POLL_ATTEMPTS: Final = 30
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BleBoxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
entities = [
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
"""Representation of BleBox updates."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool:
|
||||
"""Return True while the device hasn't yet rebooted to the new firmware."""
|
||||
return (
|
||||
self._in_progress_old_version is not None
|
||||
and self._in_progress_old_version == self._feature.installed_version
|
||||
)
|
||||
|
||||
def _sync_sw_version(self) -> None:
|
||||
"""Sync installed firmware version to the device registry."""
|
||||
if self.device_entry:
|
||||
dr.async_get(self.hass).async_update_device(
|
||||
self.device_entry.id,
|
||||
sw_version=self._feature.installed_version,
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update state and refresh sw_version in device registry."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._sync_sw_version()
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Version installed and in use."""
|
||||
return self._feature.installed_version
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Latest version available for install."""
|
||||
return self._feature.latest_version
|
||||
|
||||
def _cancel_poll(self) -> None:
|
||||
if self._poll_cancel is not None:
|
||||
self._poll_cancel()
|
||||
self._poll_cancel = None
|
||||
|
||||
def _reset_progress(self) -> None:
|
||||
self._in_progress_old_version = None
|
||||
self._poll_attempts = 0
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._cancel_poll()
|
||||
self._in_progress_old_version = self._feature.installed_version
|
||||
self._poll_attempts = 0
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self._feature.async_install()
|
||||
except Error as ex:
|
||||
self._reset_progress()
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Cancel any pending poll timer when the entity is removed."""
|
||||
self._cancel_poll()
|
||||
|
||||
async def _poll_until_updated(self, _now: Any) -> None:
|
||||
"""Poll device until the installed version changes after OTA reboot."""
|
||||
self._poll_cancel = None
|
||||
self._poll_attempts += 1
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except BleBoxConnectionError:
|
||||
pass
|
||||
except Error:
|
||||
self._reset_progress()
|
||||
return
|
||||
else:
|
||||
self._sync_sw_version()
|
||||
if self.in_progress and self._poll_attempts < _MAX_POLL_ATTEMPTS:
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
else:
|
||||
self._reset_progress()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blue_current",
|
||||
"name": "Blue Current",
|
||||
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
|
||||
"codeowners": ["@gleeuwen", "@jtodorova23"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blue_current",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -124,7 +124,9 @@ async def async_setup_entry(
|
||||
BlueMaestroBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class BlueMaestroBluetoothSensorEntity(
|
||||
|
||||
@@ -11,6 +11,7 @@ from bluetooth_adapters import (
|
||||
ADAPTER_CONNECTION_SLOTS,
|
||||
ADAPTER_HW_VERSION,
|
||||
ADAPTER_MANUFACTURER,
|
||||
ADAPTER_PASSIVE_SCAN,
|
||||
ADAPTER_SW_VERSION,
|
||||
DEFAULT_ADDRESS,
|
||||
DEFAULT_CONNECTION_SLOTS,
|
||||
@@ -69,6 +70,7 @@ from .api import (
|
||||
async_register_callback,
|
||||
async_register_scanner,
|
||||
async_remove_scanner,
|
||||
async_request_active_scan,
|
||||
async_scanner_by_source,
|
||||
async_scanner_count,
|
||||
async_scanner_devices_by_address,
|
||||
@@ -79,7 +81,6 @@ from .const import (
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
@@ -93,7 +94,7 @@ from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange
|
||||
from .storage import BluetoothStorage
|
||||
from .util import adapter_title
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -128,6 +129,7 @@ __all__ = [
|
||||
"async_register_callback",
|
||||
"async_register_scanner",
|
||||
"async_remove_scanner",
|
||||
"async_request_active_scan",
|
||||
"async_scanner_by_source",
|
||||
"async_scanner_count",
|
||||
"async_scanner_devices_by_address",
|
||||
@@ -387,12 +389,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Bluetooth adapter {adapter} with address {address} not found"
|
||||
)
|
||||
passive = entry.options.get(CONF_PASSIVE)
|
||||
adapters = await manager.async_get_bluetooth_adapters()
|
||||
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
||||
details = adapters[adapter]
|
||||
mode = resolve_scanning_mode(entry.options)
|
||||
# AUTO needs passive scanning support to flip on demand; without it
|
||||
# the scanner would start passive on hardware that can't do passive.
|
||||
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
|
||||
mode = BluetoothScanningMode.ACTIVE
|
||||
scanner = HaScanner(mode, adapter, address)
|
||||
scanner.async_setup()
|
||||
details = adapters[adapter]
|
||||
if entry.title == address:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, title=adapter_title(adapter, details)
|
||||
|
||||
@@ -68,9 +68,20 @@ class ActiveBluetoothProcessorCoordinator[_DataT](
|
||||
| None = None,
|
||||
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
|
||||
connectable: bool = True,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize the processor."""
|
||||
super().__init__(hass, logger, address, mode, update_method, connectable)
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
address,
|
||||
mode,
|
||||
update_method,
|
||||
connectable,
|
||||
scan_interval,
|
||||
scan_duration,
|
||||
)
|
||||
|
||||
self._needs_poll_method = needs_poll_method
|
||||
self._poll_method = poll_method
|
||||
|
||||
@@ -130,17 +130,26 @@ def async_register_callback(
|
||||
callback: BluetoothCallback,
|
||||
match_dict: BluetoothCallbackMatcher | None,
|
||||
mode: BluetoothScanningMode,
|
||||
*,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register to receive a callback on bluetooth change.
|
||||
|
||||
mode is currently not used as we only support active scanning.
|
||||
Passive scanning will be available in the future. The flag
|
||||
is required to be present to avoid a future breaking change
|
||||
when we support passive scanning.
|
||||
When ``mode`` is not PASSIVE and ``match_dict["address"]`` is set,
|
||||
the address is registered with habluetooth's active-scan scheduler
|
||||
so AUTO-mode scanners flip ACTIVE on demand for that device.
|
||||
``scan_interval`` / ``scan_duration`` default to habluetooth's
|
||||
DEFAULT_ACTIVE_SCAN_* (5 minutes / 10 seconds) when not provided;
|
||||
integrations that need a different cadence can pass explicit
|
||||
values. Without an address in the matcher the active-scan request
|
||||
is skipped; the callback itself still fires normally.
|
||||
|
||||
Returns a callback that can be used to cancel the registration.
|
||||
"""
|
||||
return _get_manager(hass).async_register_callback(callback, match_dict)
|
||||
return _get_manager(hass).async_register_callback(
|
||||
callback, match_dict, mode, scan_interval, scan_duration
|
||||
)
|
||||
|
||||
|
||||
async def async_process_advertisements(
|
||||
@@ -161,7 +170,7 @@ async def async_process_advertisements(
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -275,3 +284,19 @@ def async_set_fallback_availability_interval(
|
||||
) -> None:
|
||||
"""Override the fallback availability timeout for a MAC address."""
|
||||
_get_manager(hass).async_set_fallback_availability_interval(address, interval)
|
||||
|
||||
|
||||
async def async_request_active_scan(
|
||||
hass: HomeAssistant, duration: float | None = None
|
||||
) -> None:
|
||||
"""Run an on-demand active sweep across every AUTO scanner.
|
||||
|
||||
Intended for config-flow discovery and other one-shot probes that
|
||||
need fresh advertisements without waiting for the periodic
|
||||
rediscovery cadence. Awaits ``duration`` seconds so the caller can
|
||||
then read newly discovered advertisements. Defaults to habluetooth's
|
||||
on-demand sweep duration when ``duration`` is not provided; the
|
||||
scheduler clamps the value to its allowed range. Concurrent callers
|
||||
dedupe to a single bus-wide window.
|
||||
"""
|
||||
await _get_manager(hass).async_request_active_scan(duration)
|
||||
|
||||
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
|
||||
adapter_model,
|
||||
get_adapters,
|
||||
)
|
||||
from habluetooth import get_manager
|
||||
from habluetooth import BluetoothScanningMode, get_manager
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
@@ -22,33 +22,64 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaFlowFormStep,
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .util import adapter_title
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSIVE, default=False): bool,
|
||||
}
|
||||
_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
BluetoothScanningMode.AUTO.value,
|
||||
BluetoothScanningMode.ACTIVE.value,
|
||||
BluetoothScanningMode.PASSIVE.value,
|
||||
],
|
||||
translation_key="mode",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Build the options schema with the saved mode as the default."""
|
||||
current = resolve_scanning_mode(handler.options).value
|
||||
return vol.Schema({vol.Required(CONF_MODE, default=current): _MODE_SELECTOR})
|
||||
|
||||
|
||||
async def _validate_options(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Mirror CONF_MODE into the legacy CONF_PASSIVE for downgrade safety."""
|
||||
user_input[CONF_PASSIVE] = (
|
||||
user_input[CONF_MODE] == BluetoothScanningMode.PASSIVE.value
|
||||
)
|
||||
return user_input
|
||||
|
||||
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
|
||||
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,17 +7,21 @@ from habluetooth import ( # noqa: F401
|
||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
SCANNER_WATCHDOG_TIMEOUT,
|
||||
BluetoothScanningMode,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_MODE # noqa: F401
|
||||
|
||||
DOMAIN = "bluetooth"
|
||||
|
||||
CONF_ADAPTER = "adapter"
|
||||
CONF_DETAILS = "details"
|
||||
# CONF_PASSIVE is the legacy boolean option; we keep writing it alongside
|
||||
# CONF_MODE so a downgrade to a pre-AUTO release reads a sensible value.
|
||||
CONF_PASSIVE = "passive"
|
||||
|
||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||
|
||||
@@ -21,7 +21,11 @@ from habluetooth import (
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
|
||||
from homeassistant.const import (
|
||||
CONF_SOURCE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -33,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import (
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
@@ -202,6 +205,9 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
self,
|
||||
callback: BluetoothCallback,
|
||||
matcher: BluetoothCallbackMatcher | None,
|
||||
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
||||
@@ -216,15 +222,31 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
connectable = callback_matcher[CONNECTABLE]
|
||||
self._callback_index.add_callback_matcher(callback_matcher)
|
||||
|
||||
# If the matcher targets a specific address and the caller
|
||||
# didn't explicitly ask for PASSIVE, wire it into habluetooth's
|
||||
# active-scan scheduler so AUTO-mode scanners flip ACTIVE on
|
||||
# demand for this device. ``scan_interval``/``scan_duration``
|
||||
# default to habluetooth's DEFAULT_ACTIVE_SCAN_* when None.
|
||||
cancel_active_scan: Callable[[], None] | None = None
|
||||
if (
|
||||
mode is not BluetoothScanningMode.PASSIVE
|
||||
and (address := callback_matcher.get(ADDRESS)) is not None
|
||||
):
|
||||
cancel_active_scan = self.async_register_active_scan(
|
||||
address, scan_interval, scan_duration
|
||||
)
|
||||
|
||||
def _async_remove_callback() -> None:
|
||||
self._callback_index.remove_callback_matcher(callback_matcher)
|
||||
if cancel_active_scan is not None:
|
||||
cancel_active_scan()
|
||||
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
history = self._connectable_history if connectable else self._all_history
|
||||
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
||||
if address := callback_matcher.get(ADDRESS):
|
||||
if (address := callback_matcher.get(ADDRESS)) is not None:
|
||||
if service_info := history.get(address):
|
||||
service_infos = [service_info]
|
||||
else:
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.1.1",
|
||||
"bleak-retry-connector==4.6.0",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.4.0"
|
||||
"bleak==3.0.2",
|
||||
"bleak-retry-connector==4.6.1",
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -298,9 +298,13 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
|
||||
mode: BluetoothScanningMode,
|
||||
update_method: Callable[[BluetoothServiceInfoBleak], _DataT],
|
||||
connectable: bool = False,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(hass, logger, address, mode, connectable)
|
||||
super().__init__(
|
||||
hass, logger, address, mode, connectable, scan_interval, scan_duration
|
||||
)
|
||||
self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = []
|
||||
self._update_method = update_method
|
||||
self.last_update_success = True
|
||||
|
||||
@@ -48,9 +48,21 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"passive": "Passive scanning"
|
||||
"mode": "Scanning mode"
|
||||
},
|
||||
"data_description": {
|
||||
"mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"mode": {
|
||||
"options": {
|
||||
"active": "Active (uses more device battery, fastest updates)",
|
||||
"auto": "Auto (recommended, saves device battery)",
|
||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ class BasePassiveBluetoothCoordinator(ABC):
|
||||
address: str,
|
||||
mode: BluetoothScanningMode,
|
||||
connectable: bool,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.hass = hass
|
||||
@@ -38,6 +40,8 @@ class BasePassiveBluetoothCoordinator(ABC):
|
||||
self.connectable = connectable
|
||||
self._on_stop: list[CALLBACK_TYPE] = []
|
||||
self.mode = mode
|
||||
self._scan_interval = scan_interval
|
||||
self._scan_duration = scan_duration
|
||||
self._last_unavailable_time = 0.0
|
||||
self._last_name = address
|
||||
# Subclasses are responsible for setting _available to True
|
||||
@@ -92,6 +96,8 @@ class BasePassiveBluetoothCoordinator(ABC):
|
||||
address=self.address, connectable=self.connectable
|
||||
),
|
||||
self.mode,
|
||||
scan_interval=self._scan_interval,
|
||||
scan_duration=self._scan_duration,
|
||||
)
|
||||
)
|
||||
self._on_stop.append(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""The bluetooth integration utilities."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bluetooth_adapters import (
|
||||
ADAPTER_ADDRESS,
|
||||
ADAPTER_MANUFACTURER,
|
||||
@@ -9,14 +13,32 @@ from bluetooth_adapters import (
|
||||
adapter_unique_name,
|
||||
)
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
from habluetooth import get_manager
|
||||
from habluetooth import BluetoothScanningMode, get_manager
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import CONF_MODE, CONF_PASSIVE, DEFAULT_MODE
|
||||
from .models import BluetoothServiceInfoBleak
|
||||
from .storage import BluetoothStorage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_scanning_mode(options: Mapping[str, Any]) -> BluetoothScanningMode:
|
||||
"""Resolve CONF_MODE, falling back to legacy CONF_PASSIVE or DEFAULT_MODE."""
|
||||
if (mode_value := options.get(CONF_MODE)) is not None:
|
||||
try:
|
||||
return BluetoothScanningMode(mode_value)
|
||||
except TypeError, ValueError:
|
||||
_LOGGER.warning("Unknown bluetooth scanning mode %r", mode_value)
|
||||
return BluetoothScanningMode(DEFAULT_MODE)
|
||||
if (legacy_passive := options.get(CONF_PASSIVE)) is True:
|
||||
return BluetoothScanningMode.PASSIVE
|
||||
if legacy_passive is False:
|
||||
return BluetoothScanningMode.ACTIVE
|
||||
return BluetoothScanningMode(DEFAULT_MODE)
|
||||
|
||||
|
||||
class InvalidConfigEntryID(HomeAssistantError):
|
||||
"""Invalid config entry id."""
|
||||
|
||||
@@ -9,7 +9,14 @@ from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSuppor
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
CONF_CLIENT_ID,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
)
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
@@ -23,7 +30,6 @@ from homeassistant.util.network import is_host_valid
|
||||
from .const import (
|
||||
ATTR_CID,
|
||||
ATTR_MAC,
|
||||
ATTR_MODEL,
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
|
||||
@@ -6,8 +6,6 @@ from typing import Final
|
||||
ATTR_CID: Final = "cid"
|
||||
ATTR_MAC: Final = "macAddr"
|
||||
ATTR_MANUFACTURER: Final = "Sony"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
CONF_USE_PSK: Final = "use_psk"
|
||||
|
||||
@@ -17,6 +17,7 @@ BTHOME_BLE_EVENT: Final = "bthome_ble_event"
|
||||
|
||||
EVENT_CLASS_BUTTON: Final = "button"
|
||||
EVENT_CLASS_DIMMER: Final = "dimmer"
|
||||
EVENT_CLASS_COMMAND: Final = "command"
|
||||
|
||||
CONF_EVENT_CLASS: Final = "event_class"
|
||||
CONF_EVENT_PROPERTIES: Final = "event_properties"
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
EVENT_CLASS,
|
||||
EVENT_CLASS_BUTTON,
|
||||
EVENT_CLASS_COMMAND,
|
||||
EVENT_CLASS_DIMMER,
|
||||
EVENT_TYPE,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ EVENT_TYPES_BY_EVENT_CLASS = {
|
||||
"hold_press",
|
||||
},
|
||||
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
|
||||
EVENT_CLASS_COMMAND: {"off", "on", "toggle", "step_up", "step_down"},
|
||||
}
|
||||
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
|
||||
@@ -16,6 +16,7 @@ from . import format_discovered_event_class, format_event_dispatcher_name
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_CLASS_BUTTON,
|
||||
EVENT_CLASS_COMMAND,
|
||||
EVENT_CLASS_DIMMER,
|
||||
EVENT_PROPERTIES,
|
||||
EVENT_TYPE,
|
||||
@@ -43,6 +44,11 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
|
||||
translation_key="dimmer",
|
||||
event_types=["rotate_left", "rotate_right"],
|
||||
),
|
||||
EVENT_CLASS_COMMAND: EventEntityDescription(
|
||||
key=EVENT_CLASS_COMMAND,
|
||||
translation_key="command",
|
||||
event_types=["off", "on", "toggle", "step_up", "step_down"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.17.0"]
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Light level (-)
|
||||
(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL, None): SensorEntityDescription(
|
||||
key=str(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="light_level",
|
||||
),
|
||||
# Mass sensor (kg)
|
||||
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
|
||||
@@ -287,6 +293,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="rotational_speed",
|
||||
),
|
||||
# Settings revision (-)
|
||||
(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION, None): SensorEntityDescription(
|
||||
key=str(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="settings_revision",
|
||||
),
|
||||
# Signal Strength (RSSI) (dB)
|
||||
(
|
||||
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
||||
|
||||
@@ -36,13 +36,19 @@
|
||||
"long_double_press": "Long Double Press",
|
||||
"long_press": "Long Press",
|
||||
"long_triple_press": "Long Triple Press",
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"press": "Press",
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right",
|
||||
"step_down": "Step Down",
|
||||
"step_up": "Step Up",
|
||||
"toggle": "Toggle",
|
||||
"triple_press": "Triple Press"
|
||||
},
|
||||
"trigger_type": {
|
||||
"button": "Button \"{subtype}\"",
|
||||
"command": "Command \"{subtype}\"",
|
||||
"dimmer": "Dimmer \"{subtype}\""
|
||||
}
|
||||
},
|
||||
@@ -68,6 +74,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"step_down": "Step down",
|
||||
"step_up": "Step up",
|
||||
"toggle": "Toggle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dimmer": {
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
@@ -98,6 +117,9 @@
|
||||
"gyroscope": {
|
||||
"name": "Gyroscope"
|
||||
},
|
||||
"light_level": {
|
||||
"name": "Light level"
|
||||
},
|
||||
"packet_id": {
|
||||
"name": "Packet ID"
|
||||
},
|
||||
@@ -110,6 +132,9 @@
|
||||
"rotational_speed": {
|
||||
"name": "Rotational speed"
|
||||
},
|
||||
"settings_revision": {
|
||||
"name": "Settings revision"
|
||||
},
|
||||
"text": {
|
||||
"name": "Text"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
CONF_NAME,
|
||||
CONF_RECIPIENT,
|
||||
CONF_USERNAME,
|
||||
@@ -29,8 +30,6 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
|
||||
|
||||
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_VOICE = "voice"
|
||||
|
||||
MALE_VOICE = "male"
|
||||
|
||||
@@ -275,9 +275,13 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
)
|
||||
)
|
||||
|
||||
def should_expose(self, state: State) -> bool:
|
||||
"""If a state object should be exposed."""
|
||||
return self._should_expose_entity_id(state.entity_id)
|
||||
def should_expose(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity ID should be exposed."""
|
||||
@@ -308,14 +312,6 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
and _supported_legacy(self.hass, entity_id)
|
||||
)
|
||||
|
||||
def _should_expose_entity_id(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
@property
|
||||
def agent_user_id(self) -> str:
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
@@ -467,7 +463,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
entity_id = event.data["entity_id"]
|
||||
|
||||
if not self._should_expose_entity_id(entity_id):
|
||||
if not self.should_expose(entity_id):
|
||||
return
|
||||
|
||||
self.async_schedule_google_sync_all()
|
||||
@@ -490,8 +486,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
# Check if any exposed entity uses the device area
|
||||
if not any(
|
||||
entity_entry.area_id is None
|
||||
and self._should_expose_entity_id(entity_entry.entity_id)
|
||||
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
|
||||
for entity_entry in er.async_entries_for_device(
|
||||
er.async_get(self.hass), event.data["device_id"]
|
||||
)
|
||||
|
||||
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import R2ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300
|
||||
|
||||
@@ -13,6 +13,7 @@ from botocore.exceptions import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -25,7 +26,6 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
|
||||
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
# R2 is S3-compatible. Endpoint should be like:
|
||||
# https://<accountid>.r2.cloudflarestorage.com
|
||||
|
||||
@@ -5,6 +5,3 @@ ATTR_URL = "color_extract_url"
|
||||
|
||||
DOMAIN = "color_extractor"
|
||||
DEFAULT_NAME = "Color extractor"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_ON = "turn_on"
|
||||
|
||||
@@ -14,11 +14,11 @@ from homeassistant.components.light import (
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
LIGHT_TURN_ON_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
|
||||
from homeassistant.const import SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -141,7 +141,7 @@ async def async_handle_service(service_call: ServiceCall) -> None:
|
||||
service_data[ATTR_RGB_COLOR] = color
|
||||
|
||||
await service_call.hass.services.async_call(
|
||||
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
|
||||
LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ class CheckConfigView(HomeAssistantView):
|
||||
vol.Optional("location_name"): str,
|
||||
vol.Optional("longitude"): cv.longitude,
|
||||
vol.Optional("radius"): cv.positive_int,
|
||||
vol.Optional("time_zone"): cv.time_zone,
|
||||
# Validated by async_set_time_zone in the executor to avoid
|
||||
# blocking I/O loading zoneinfo data on the event loop.
|
||||
vol.Optional("time_zone"): str,
|
||||
vol.Optional("update_units"): bool,
|
||||
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,35 @@ async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def _migrate_identifiers(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CookidooConfigEntry,
|
||||
old_prefix: str,
|
||||
new_unique_id: str,
|
||||
) -> None:
|
||||
"""Migrate device identifiers and entity unique_ids from old to new prefix."""
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
for dev in device_entries:
|
||||
new_identifiers = {
|
||||
(DOMAIN, new_unique_id) if domain == DOMAIN else (domain, identifier)
|
||||
for domain, identifier in dev.identifiers
|
||||
}
|
||||
device_registry.async_update_device(dev.id, new_identifiers=new_identifiers)
|
||||
for ent in entity_entries:
|
||||
if ent.unique_id and ent.unique_id.startswith(f"{old_prefix}_"):
|
||||
entity_registry.async_update_entity(
|
||||
ent.entity_id,
|
||||
new_unique_id=f"{new_unique_id}{ent.unique_id[len(old_prefix) :]}",
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: CookidooConfigEntry
|
||||
) -> bool:
|
||||
@@ -49,41 +78,37 @@ async def async_migrate_entry(
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
# Add the unique uuid
|
||||
# Add the unique uuid (first migration, entities used config_entry_id as prefix)
|
||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
||||
|
||||
try:
|
||||
auth_data = await cookidoo.login()
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
_LOGGER.error(
|
||||
"Could not migrate config config_entry: %s",
|
||||
str(e),
|
||||
)
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
unique_id = auth_data.sub
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry_id=config_entry.entry_id
|
||||
)
|
||||
for dev in device_entries:
|
||||
device_registry.async_update_device(
|
||||
dev.id, new_identifiers={(DOMAIN, unique_id)}
|
||||
)
|
||||
for ent in entity_entries:
|
||||
assert ent.config_entry_id
|
||||
entity_registry.async_update_entity(
|
||||
ent.entity_id,
|
||||
new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
|
||||
)
|
||||
|
||||
_migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=auth_data.sub, minor_version=2
|
||||
config_entry, unique_id=user_info.id, minor_version=3
|
||||
)
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 2:
|
||||
# Migrate unique_id from old CIAM sub to community profile id
|
||||
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
|
||||
|
||||
try:
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
old_unique_id = config_entry.unique_id
|
||||
if old_unique_id:
|
||||
_migrate_identifiers(hass, config_entry, old_unique_id, user_info.id)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=user_info.id, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooException
|
||||
from cookidoo_api import (
|
||||
CookidooAuthException,
|
||||
CookidooException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
@@ -74,7 +78,13 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
week_day
|
||||
)
|
||||
except CookidooAuthException:
|
||||
await self.coordinator.cookidoo.refresh_token()
|
||||
try:
|
||||
await self.coordinator.cookidoo.login()
|
||||
except (CookidooAuthException, CookidooRequestException) as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="calendar_fetch_failed",
|
||||
) from exc
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cookidoo."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
COUNTRY_DATA_SCHEMA: dict
|
||||
LANGUAGE_DATA_SCHEMA: dict
|
||||
@@ -223,8 +223,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
|
||||
try:
|
||||
auth_data = await cookidoo.login()
|
||||
self.user_uuid = auth_data.sub
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
self.user_uuid = user_info.id
|
||||
if language_input:
|
||||
await cookidoo.get_additional_items()
|
||||
except CookidooRequestException:
|
||||
|
||||
@@ -87,7 +87,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
)
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.cookidoo.refresh_token()
|
||||
await self.cookidoo.login()
|
||||
except CookidooAuthException as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -96,6 +96,11 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||
},
|
||||
) from exc
|
||||
except CookidooRequestException as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
) from exc
|
||||
_LOGGER.debug(
|
||||
"Authentication failed but re-authentication"
|
||||
" was successful, trying again later"
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .coordinator import CookidooConfigEntry
|
||||
|
||||
@@ -21,7 +22,7 @@ async def cookidoo_from_config_data(
|
||||
)
|
||||
|
||||
return Cookidoo(
|
||||
async_get_clientsession(hass),
|
||||
async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
|
||||
CookidooConfig(
|
||||
email=data[CONF_EMAIL],
|
||||
password=data[CONF_PASSWORD],
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["cookidoo_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["cookidoo-api==0.14.0"]
|
||||
"requirements": ["cookidoo-api==0.17.2"]
|
||||
}
|
||||
|
||||
@@ -3,23 +3,14 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .config_entry import ( # noqa: F401
|
||||
DATA_COMPONENT,
|
||||
BaseScannerEntity,
|
||||
BaseTrackerEntity,
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
TrackerEntityDescription,
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
)
|
||||
from .const import ( # noqa: F401
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BATTERY,
|
||||
@@ -45,6 +36,14 @@ from .const import ( # noqa: F401
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
from .entity import ( # noqa: F401
|
||||
BaseScannerEntity,
|
||||
BaseTrackerEntity,
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
TrackerEntityDescription,
|
||||
)
|
||||
from .legacy import ( # noqa: F401
|
||||
PLATFORM_SCHEMA,
|
||||
PLATFORM_SCHEMA_BASE,
|
||||
@@ -60,6 +59,8 @@ from .legacy import ( # noqa: F401
|
||||
see,
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
"""Return the state if any or a specified device is home."""
|
||||
@@ -108,3 +109,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
eager_start=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up an entry."""
|
||||
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
|
||||
|
||||
if component is not None:
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
|
||||
LOGGER, DOMAIN, hass
|
||||
)
|
||||
component.register_shutdown()
|
||||
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
@@ -1,472 +1,45 @@
|
||||
"""Code to set up a device tracker platform using a config entry."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
from functools import partial
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components import zone
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
ATTR_GPS_ACCURACY,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
EventDeviceRegistryUpdatedData,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedAlias,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up an entry."""
|
||||
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
|
||||
|
||||
if component is not None:
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
|
||||
LOGGER, DOMAIN, hass
|
||||
)
|
||||
component.register_shutdown()
|
||||
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_connected_device_registered(
|
||||
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
|
||||
) -> None:
|
||||
"""Register a newly seen connected device.
|
||||
|
||||
This is currently used by the dhcp integration
|
||||
to listen for newly registered connected devices
|
||||
for discovery.
|
||||
"""
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
{
|
||||
ATTR_IP: ip_address,
|
||||
ATTR_MAC: mac,
|
||||
ATTR_HOST_NAME: hostname,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_register_mac(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
mac: str,
|
||||
unique_id: str,
|
||||
) -> None:
|
||||
"""Register a mac address with a unique ID."""
|
||||
mac = dr.format_mac(mac)
|
||||
if DATA_KEY in hass.data:
|
||||
hass.data[DATA_KEY][mac] = (domain, unique_id)
|
||||
return
|
||||
|
||||
# Setup listening.
|
||||
|
||||
# dict mapping mac -> partial unique ID
|
||||
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
|
||||
|
||||
@callback
|
||||
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
|
||||
"""Enable the online status entity for the mac of a newly created device."""
|
||||
# Only for new devices
|
||||
if ev.data["action"] != "create":
|
||||
return
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device_entry = dev_reg.async_get(ev.data["device_id"])
|
||||
|
||||
if device_entry is None:
|
||||
# This should not happen, since the device was just created.
|
||||
return
|
||||
|
||||
# Check if device has a mac
|
||||
mac = None
|
||||
for conn in device_entry.connections:
|
||||
if conn[0] == dr.CONNECTION_NETWORK_MAC:
|
||||
mac = conn[1]
|
||||
break
|
||||
|
||||
if mac is None:
|
||||
return
|
||||
|
||||
# Check if we have an entity for this mac
|
||||
if (unique_id := data.get(mac)) is None:
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
|
||||
return
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
# Make sure entity has a config entry and was disabled by the
|
||||
# default disable logic in the integration and new entities
|
||||
# are allowed to be added.
|
||||
if (
|
||||
entity_entry.config_entry_id is None
|
||||
or (
|
||||
(
|
||||
config_entry := hass.config_entries.async_get_entry(
|
||||
entity_entry.config_entry_id
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and config_entry.pref_disable_new_entities
|
||||
)
|
||||
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
||||
):
|
||||
return
|
||||
|
||||
# Enable entity
|
||||
ent_reg.async_update_entity(entity_id, disabled_by=None)
|
||||
|
||||
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
|
||||
|
||||
|
||||
class BaseTrackerEntity(Entity):
|
||||
"""Represent a tracked device.
|
||||
|
||||
Not intended to be directly inherited by integrations. Integrations should
|
||||
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
|
||||
"""
|
||||
|
||||
_attr_device_info: None = None
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
if hasattr(self, "_attr_source_type"):
|
||||
return self._attr_source_type
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
|
||||
|
||||
if self.battery_level is not None:
|
||||
attr[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes tracker entities."""
|
||||
|
||||
|
||||
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
|
||||
"latitude",
|
||||
"location_accuracy",
|
||||
"location_name",
|
||||
"longitude",
|
||||
}
|
||||
|
||||
|
||||
class TrackerEntity(
|
||||
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
|
||||
):
|
||||
"""Base class for a tracked device."""
|
||||
|
||||
entity_description: TrackerEntityDescription
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def force_update(self) -> bool:
|
||||
"""All updates need to be written to the state machine if we're not polling."""
|
||||
return not self.should_poll
|
||||
|
||||
@cached_property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
return self._attr_location_accuracy
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
return self._attr_latitude
|
||||
|
||||
@cached_property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
return self._attr_longitude
|
||||
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.location_name is not None:
|
||||
return self.location_name
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
zone_state = self.__active_zone
|
||||
if zone_state is None:
|
||||
state = STATE_NOT_HOME
|
||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||
state = STATE_HOME
|
||||
else:
|
||||
state = zone_state.name
|
||||
return state
|
||||
|
||||
return None
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
attr[ATTR_IN_ZONES] = self.__in_zones or []
|
||||
attr[ATTR_LATITUDE] = self.latitude
|
||||
attr[ATTR_LONGITUDE] = self.longitude
|
||||
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class BaseScannerEntity(BaseTrackerEntity):
|
||||
"""Base class for a tracked device that can be connected or disconnected.
|
||||
|
||||
Unlike ScannerEntity, this entity does not make assumptions about MAC
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is None:
|
||||
return None
|
||||
if self.is_connected:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool | None:
|
||||
"""Return true if the device is connected."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes tracker entities."""
|
||||
|
||||
|
||||
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
|
||||
"ip_address",
|
||||
"mac_address",
|
||||
"hostname",
|
||||
}
|
||||
|
||||
|
||||
class ScannerEntity(
|
||||
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
||||
):
|
||||
"""Base class for a tracked device that is on a scanned network."""
|
||||
|
||||
entity_description: ScannerEntityDescription
|
||||
_attr_hostname: str | None = None
|
||||
_attr_ip_address: str | None = None
|
||||
_attr_mac_address: str | None = None
|
||||
_attr_source_type: SourceType = SourceType.ROUTER
|
||||
|
||||
@cached_property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the primary ip address of the device."""
|
||||
return self._attr_ip_address
|
||||
|
||||
@cached_property
|
||||
def mac_address(self) -> str | None:
|
||||
"""Return the mac address of the device."""
|
||||
return self._attr_mac_address
|
||||
|
||||
@cached_property
|
||||
def hostname(self) -> str | None:
|
||||
"""Return hostname of the device."""
|
||||
return self._attr_hostname
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return unique ID of the entity."""
|
||||
return self.mac_address
|
||||
|
||||
@final
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Device tracker entities should not create device registry entries."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if entity is enabled by default."""
|
||||
# If mac_address is None, we can never find a device entry.
|
||||
return (
|
||||
# Do not disable if we won't activate our attach to device logic
|
||||
self.mac_address is None
|
||||
or self.device_info is not None
|
||||
# Disable if we automatically attach but there is no device
|
||||
or self.find_device_entry() is not None
|
||||
)
|
||||
|
||||
@callback
|
||||
def add_to_platform_start(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
platform: EntityPlatform,
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||
if self.mac_address and self.unique_id:
|
||||
_async_register_mac(
|
||||
hass,
|
||||
platform.platform_name,
|
||||
self.mac_address,
|
||||
self.unique_id,
|
||||
)
|
||||
if self.is_connected and self.ip_address:
|
||||
_async_connected_device_registered(
|
||||
hass,
|
||||
self.mac_address,
|
||||
self.ip_address,
|
||||
self.hostname,
|
||||
)
|
||||
|
||||
@callback
|
||||
def find_device_entry(self) -> dr.DeviceEntry | None:
|
||||
"""Return device entry."""
|
||||
assert self.mac_address is not None
|
||||
|
||||
return dr.async_get(self.hass).async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
|
||||
)
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Handle added to Home Assistant."""
|
||||
# Entities without a unique ID don't have a device
|
||||
if (
|
||||
not self.registry_entry
|
||||
or not self.platform.config_entry
|
||||
or not self.mac_address
|
||||
or (device_entry := self.find_device_entry()) is None
|
||||
# Entities should not have a device info. We opt them out
|
||||
# of this logic if they do.
|
||||
or self.device_info
|
||||
):
|
||||
if self.device_info:
|
||||
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
|
||||
await super().async_internal_added_to_hass()
|
||||
return
|
||||
|
||||
# Attach entry to device
|
||||
if self.registry_entry.device_id != device_entry.id:
|
||||
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
||||
self.entity_id, device_id=device_entry.id
|
||||
)
|
||||
|
||||
# Attach device to config entry
|
||||
if self.platform.config_entry.entry_id not in device_entry.config_entries:
|
||||
dr.async_get(self.hass).async_update_device(
|
||||
device_entry.id,
|
||||
add_config_entry_id=self.platform.config_entry.entry_id,
|
||||
)
|
||||
|
||||
# Do this last or else the entity registry update listener has been installed
|
||||
await super().async_internal_added_to_hass()
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
"""Return the device state attributes."""
|
||||
attr = super().state_attributes
|
||||
|
||||
if ip_address := self.ip_address:
|
||||
attr[ATTR_IP] = ip_address
|
||||
if (mac_address := self.mac_address) is not None:
|
||||
attr[ATTR_MAC] = mac_address
|
||||
if (hostname := self.hostname) is not None:
|
||||
attr[ATTR_HOST_NAME] = hostname
|
||||
|
||||
return attr
|
||||
from . import (
|
||||
BaseTrackerEntity as _BaseTrackerEntity,
|
||||
ScannerEntity as _ScannerEntity,
|
||||
SourceType as _SourceType,
|
||||
TrackerEntity as _TrackerEntity,
|
||||
TrackerEntityDescription as _TrackerEntityDescription,
|
||||
)
|
||||
|
||||
_DEPRECATED_TrackerEntity = DeprecatedAlias(
|
||||
_TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6"
|
||||
)
|
||||
_DEPRECATED_ScannerEntity = DeprecatedAlias(
|
||||
_ScannerEntity, "homeassistant.components.device_tracker.ScannerEntity", "2027.6"
|
||||
)
|
||||
_DEPRECATED_BaseTrackerEntity = DeprecatedAlias(
|
||||
_BaseTrackerEntity,
|
||||
"homeassistant.components.device_tracker.BaseTrackerEntity",
|
||||
"2027.6",
|
||||
)
|
||||
_DEPRECATED_TrackerEntityDescription = DeprecatedAlias(
|
||||
_TrackerEntityDescription,
|
||||
"homeassistant.components.device_tracker.TrackerEntityDescription",
|
||||
"2027.6",
|
||||
)
|
||||
_DEPRECATED_SourceType = DeprecatedAlias(
|
||||
_SourceType, "homeassistant.components.device_tracker.SourceType", "2027.6"
|
||||
)
|
||||
|
||||
# These can be removed if no deprecated aliases are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components import zone
|
||||
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
ATTR_GPS_ACCURACY,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
EventDeviceRegistryUpdatedData,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
|
||||
@callback
|
||||
def _async_connected_device_registered(
|
||||
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
|
||||
) -> None:
|
||||
"""Register a newly seen connected device.
|
||||
|
||||
This is currently used by the dhcp integration
|
||||
to listen for newly registered connected devices
|
||||
for discovery.
|
||||
"""
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
{
|
||||
ATTR_IP: ip_address,
|
||||
ATTR_MAC: mac,
|
||||
ATTR_HOST_NAME: hostname,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_register_mac(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
mac: str,
|
||||
unique_id: str,
|
||||
) -> None:
|
||||
"""Register a mac address with a unique ID."""
|
||||
mac = dr.format_mac(mac)
|
||||
if DATA_KEY in hass.data:
|
||||
hass.data[DATA_KEY][mac] = (domain, unique_id)
|
||||
return
|
||||
|
||||
# Setup listening.
|
||||
|
||||
# dict mapping mac -> partial unique ID
|
||||
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
|
||||
|
||||
@callback
|
||||
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
|
||||
"""Enable the online status entity for the mac of a newly created device."""
|
||||
# Only for new devices
|
||||
if ev.data["action"] != "create":
|
||||
return
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device_entry = dev_reg.async_get(ev.data["device_id"])
|
||||
|
||||
if device_entry is None:
|
||||
# This should not happen, since the device was just created.
|
||||
return
|
||||
|
||||
# Check if device has a mac
|
||||
mac = None
|
||||
for conn in device_entry.connections:
|
||||
if conn[0] == dr.CONNECTION_NETWORK_MAC:
|
||||
mac = conn[1]
|
||||
break
|
||||
|
||||
if mac is None:
|
||||
return
|
||||
|
||||
# Check if we have an entity for this mac
|
||||
if (unique_id := data.get(mac)) is None:
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
|
||||
return
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
# Make sure entity has a config entry and was disabled by the
|
||||
# default disable logic in the integration and new entities
|
||||
# are allowed to be added.
|
||||
if (
|
||||
entity_entry.config_entry_id is None
|
||||
or (
|
||||
(
|
||||
config_entry := hass.config_entries.async_get_entry(
|
||||
entity_entry.config_entry_id
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and config_entry.pref_disable_new_entities
|
||||
)
|
||||
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
||||
):
|
||||
return
|
||||
|
||||
# Enable entity
|
||||
ent_reg.async_update_entity(entity_id, disabled_by=None)
|
||||
|
||||
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
|
||||
|
||||
|
||||
class BaseTrackerEntity(Entity):
|
||||
"""Represent a tracked device.
|
||||
|
||||
Not intended to be directly inherited by integrations. Integrations should
|
||||
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
|
||||
"""
|
||||
|
||||
_attr_device_info: None = None
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
if hasattr(self, "_attr_source_type"):
|
||||
return self._attr_source_type
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type}
|
||||
|
||||
if self.battery_level is not None:
|
||||
attr[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes tracker entities."""
|
||||
|
||||
|
||||
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
|
||||
"in_zones",
|
||||
"latitude",
|
||||
"location_accuracy",
|
||||
"location_name",
|
||||
"longitude",
|
||||
}
|
||||
|
||||
|
||||
class TrackerEntity(
|
||||
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
|
||||
):
|
||||
"""Base class for a tracked device."""
|
||||
|
||||
entity_description: TrackerEntityDescription
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def force_update(self) -> bool:
|
||||
"""All updates need to be written to the state machine if we're not polling."""
|
||||
return not self.should_poll
|
||||
|
||||
@cached_property
|
||||
def in_zones(self) -> list[str] | None:
|
||||
"""Return the entity_id of zones the device is currently in.
|
||||
|
||||
The list may be in any order; the base class sorts it by zone radius
|
||||
and discards zones which do not exist. Ignored if latitude and
|
||||
longitude are both set.
|
||||
"""
|
||||
return self._attr_in_zones
|
||||
|
||||
@cached_property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
return self._attr_location_accuracy
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
return self._attr_latitude
|
||||
|
||||
@cached_property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
return self._attr_longitude
|
||||
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
elif (zones := self.in_zones) is not None:
|
||||
zone_states = sorted(
|
||||
(
|
||||
zone_state
|
||||
for entity_id in zones
|
||||
if (zone_state := self.hass.states.get(entity_id)) is not None
|
||||
),
|
||||
key=lambda z: z.attributes[ATTR_RADIUS],
|
||||
)
|
||||
self.__active_zone = next(
|
||||
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
|
||||
None,
|
||||
)
|
||||
self.__in_zones = [z.entity_id for z in zone_states]
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.location_name is not None:
|
||||
return self.location_name
|
||||
|
||||
if (
|
||||
self.latitude is not None and self.longitude is not None
|
||||
) or self.__in_zones is not None:
|
||||
zone_state = self.__active_zone
|
||||
if zone_state is None:
|
||||
state = STATE_NOT_HOME
|
||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||
state = STATE_HOME
|
||||
else:
|
||||
state = zone_state.name
|
||||
return state
|
||||
|
||||
return None
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
attr[ATTR_LATITUDE] = self.latitude
|
||||
attr[ATTR_LONGITUDE] = self.longitude
|
||||
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class BaseScannerEntity(BaseTrackerEntity):
|
||||
"""Base class for a tracked device that can be connected or disconnected.
|
||||
|
||||
Unlike ScannerEntity, this entity does not make assumptions about MAC
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is None:
|
||||
return None
|
||||
if self.is_connected:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool | None:
|
||||
"""Return true if the device is connected."""
|
||||
raise NotImplementedError
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if not self.is_connected:
|
||||
return attr
|
||||
|
||||
attr[ATTR_IN_ZONES] = [
|
||||
zone.ENTITY_ID_HOME,
|
||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
||||
]
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes tracker entities."""
|
||||
|
||||
|
||||
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
|
||||
"ip_address",
|
||||
"mac_address",
|
||||
"hostname",
|
||||
}
|
||||
|
||||
|
||||
class ScannerEntity(
|
||||
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
||||
):
|
||||
"""Base class for a tracked device that is on a scanned network."""
|
||||
|
||||
entity_description: ScannerEntityDescription
|
||||
_attr_hostname: str | None = None
|
||||
_attr_ip_address: str | None = None
|
||||
_attr_mac_address: str | None = None
|
||||
_attr_source_type: SourceType = SourceType.ROUTER
|
||||
|
||||
@cached_property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the primary ip address of the device."""
|
||||
return self._attr_ip_address
|
||||
|
||||
@cached_property
|
||||
def mac_address(self) -> str | None:
|
||||
"""Return the mac address of the device."""
|
||||
return self._attr_mac_address
|
||||
|
||||
@cached_property
|
||||
def hostname(self) -> str | None:
|
||||
"""Return hostname of the device."""
|
||||
return self._attr_hostname
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return unique ID of the entity."""
|
||||
return self.mac_address
|
||||
|
||||
@final
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Device tracker entities should not create device registry entries."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if entity is enabled by default."""
|
||||
# If mac_address is None, we can never find a device entry.
|
||||
return (
|
||||
# Do not disable if we won't activate our attach to device logic
|
||||
self.mac_address is None
|
||||
or self.device_info is not None
|
||||
# Disable if we automatically attach but there is no device
|
||||
or self.find_device_entry() is not None
|
||||
)
|
||||
|
||||
@callback
|
||||
def add_to_platform_start(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
platform: EntityPlatform,
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||
if self.mac_address and self.unique_id:
|
||||
_async_register_mac(
|
||||
hass,
|
||||
platform.platform_name,
|
||||
self.mac_address,
|
||||
self.unique_id,
|
||||
)
|
||||
if self.is_connected and self.ip_address:
|
||||
_async_connected_device_registered(
|
||||
hass,
|
||||
self.mac_address,
|
||||
self.ip_address,
|
||||
self.hostname,
|
||||
)
|
||||
|
||||
@callback
|
||||
def find_device_entry(self) -> dr.DeviceEntry | None:
|
||||
"""Return device entry."""
|
||||
assert self.mac_address is not None
|
||||
|
||||
return dr.async_get(self.hass).async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
|
||||
)
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Handle added to Home Assistant."""
|
||||
# Entities without a unique ID don't have a device
|
||||
if (
|
||||
not self.registry_entry
|
||||
or not self.platform.config_entry
|
||||
or not self.mac_address
|
||||
or (device_entry := self.find_device_entry()) is None
|
||||
# Entities should not have a device info. We opt them out
|
||||
# of this logic if they do.
|
||||
or self.device_info
|
||||
):
|
||||
if self.device_info:
|
||||
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
|
||||
await super().async_internal_added_to_hass()
|
||||
return
|
||||
|
||||
# Attach entry to device
|
||||
if self.registry_entry.device_id != device_entry.id:
|
||||
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
||||
self.entity_id, device_id=device_entry.id
|
||||
)
|
||||
|
||||
# Attach device to config entry
|
||||
if self.platform.config_entry.entry_id not in device_entry.config_entries:
|
||||
dr.async_get(self.hass).async_update_device(
|
||||
device_entry.id,
|
||||
add_config_entry_id=self.platform.config_entry.entry_id,
|
||||
)
|
||||
|
||||
# Do this last or else the entity registry update listener has been installed
|
||||
await super().async_internal_added_to_hass()
|
||||
|
||||
# BaseScannerEntity.state_attributes is @final to keep external subclasses
|
||||
# from tampering with it; ScannerEntity is an in-tree subclass that
|
||||
# intentionally extends it with ip/mac/hostname.
|
||||
@final # type: ignore[misc]
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr = super().state_attributes
|
||||
|
||||
if ip_address := self.ip_address:
|
||||
attr[ATTR_IP] = ip_address
|
||||
if (mac_address := self.mac_address) is not None:
|
||||
attr[ATTR_MAC] = mac_address
|
||||
if (hostname := self.hostname) is not None:
|
||||
attr[ATTR_HOST_NAME] = hostname
|
||||
|
||||
return attr
|
||||
@@ -15,8 +15,8 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==3.2.3",
|
||||
"cached-ipaddress==1.0.1"
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ from typing import TYPE_CHECKING, Any
|
||||
from dropmqttapi.discovery import DropDiscovery
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_DATA_TOPIC,
|
||||
CONF_DEVICE_DESC,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_DEVICE_OWNER_ID,
|
||||
CONF_DEVICE_TYPE,
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
CONF_COMMAND_TOPIC = "drop_command_topic"
|
||||
CONF_DATA_TOPIC = "drop_data_topic"
|
||||
CONF_DEVICE_DESC = "device_desc"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_HUB_ID = "drop_hub_id"
|
||||
CONF_DEVICE_NAME = "name"
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
TEMPERATURE,
|
||||
EntityCategory,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
@@ -49,8 +50,6 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
|
||||
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
|
||||
LOW_SYSTEM_PRESSURE = "low_system_pressure"
|
||||
BATTERY = "battery"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
TEMPERATURE = "temperature"
|
||||
INLET_TDS = "inlet_tds"
|
||||
OUTLET_TDS = "outlet_tds"
|
||||
CARTRIDGE_1_LIFE = "cart1"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyecobee"],
|
||||
"requirements": ["python-ecobee-api==0.3.2"],
|
||||
"requirements": ["python-ecobee-api==0.4.0"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERIAL_PORT): str,
|
||||
vol.Required(CONF_SERIAL_PORT): SerialPortSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/edl21",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"],
|
||||
"requirements": ["pysml==0.1.5"]
|
||||
"requirements": ["pysml==0.1.7"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for EDL21 Smart Meters."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sml import SmlGetListResponse
|
||||
@@ -29,7 +28,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
CONF_SERIAL_PORT,
|
||||
@@ -39,8 +37,6 @@ from .const import (
|
||||
SIGNAL_EDL21_TELEGRAM,
|
||||
)
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
# OBIS format: A-B:C.D.E*F
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
# A=1: Electricity
|
||||
@@ -391,8 +387,6 @@ class EDL21Entity(SensorEntity):
|
||||
self._electricity_id = electricity_id
|
||||
self._obis = obis
|
||||
self._telegram = telegram
|
||||
self._min_time = MIN_TIME_BETWEEN_UPDATES
|
||||
self._last_update = utcnow()
|
||||
self._async_remove_dispatcher = None
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{electricity_id}_{obis}"
|
||||
@@ -414,12 +408,7 @@ class EDL21Entity(SensorEntity):
|
||||
if self._telegram == telegram:
|
||||
return
|
||||
|
||||
now = utcnow()
|
||||
if now - self._last_update < self._min_time:
|
||||
return
|
||||
|
||||
self._telegram = telegram
|
||||
self._last_update = now
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._async_remove_dispatcher = async_dispatcher_connect(
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"serial_port": "[%key:common::config_flow::data::usb_path%]"
|
||||
"serial_port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"serial_port": "Serial port path to connect to"
|
||||
},
|
||||
"title": "Add your EDL21 smart meter"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from elevenlabs.core import ApiError
|
||||
from httpx import ConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.const import CONF_API_KEY, CONF_MODEL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@@ -17,7 +17,7 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_MODEL, CONF_STT_MODEL
|
||||
from .const import CONF_STT_MODEL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from elevenlabs.core import ApiError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.const import CONF_API_KEY, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -20,7 +20,6 @@ from homeassistant.helpers.selector import (
|
||||
from . import ElevenLabsConfigEntry
|
||||
from .const import (
|
||||
CONF_CONFIGURE_VOICE,
|
||||
CONF_MODEL,
|
||||
CONF_SIMILARITY,
|
||||
CONF_STABILITY,
|
||||
CONF_STT_AUTO_LANGUAGE,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
"""Constants for the ElevenLabs text-to-speech integration."""
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
|
||||
CONF_VOICE = "voice"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_MODEL = "model"
|
||||
CONF_CONFIGURE_VOICE = "configure_voice"
|
||||
CONF_STABILITY = "stability"
|
||||
CONF_SIMILARITY = "similarity"
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.components.tts import (
|
||||
TtsAudioType,
|
||||
Voice,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import ATTR_MODEL, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
@@ -28,7 +28,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElevenLabsConfigEntry
|
||||
from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_SIMILARITY,
|
||||
CONF_STABILITY,
|
||||
CONF_STYLE,
|
||||
|
||||
@@ -10,7 +10,7 @@ from aiohttp import ClientError, ClientResponseError
|
||||
from energyid_webhooks.client_v2 import WebhookClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.const import CONF_DEVICE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -28,7 +28,6 @@ from homeassistant.helpers.event import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_ENERGYID_KEY,
|
||||
CONF_HA_ENTITY_UUID,
|
||||
|
||||
@@ -15,13 +15,13 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_PROVISIONING_KEY,
|
||||
CONF_PROVISIONING_SECRET,
|
||||
|
||||
@@ -8,8 +8,6 @@ NAME: Final = "EnergyID"
|
||||
# --- Config Flow and Entry Data ---
|
||||
CONF_PROVISIONING_KEY: Final = "provisioning_key"
|
||||
CONF_PROVISIONING_SECRET: Final = "provisioning_secret"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_ID: Final = "device_id"
|
||||
CONF_DEVICE_NAME: Final = "device_name"
|
||||
|
||||
# --- Subentry (Mapping) Data ---
|
||||
|
||||
@@ -4,10 +4,10 @@ from functools import partial
|
||||
|
||||
from aioesphomeapi import (
|
||||
AlarmControlPanelCommand,
|
||||
AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelEntityState as ESPHomeAlarmControlPanelEntityState,
|
||||
AlarmControlPanelInfo,
|
||||
AlarmControlPanelState as ESPHomeAlarmControlPanelState,
|
||||
APIIntEnum,
|
||||
EntityInfo,
|
||||
)
|
||||
|
||||
@@ -50,16 +50,28 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EspHomeACPFeatures(APIIntEnum):
|
||||
"""ESPHome AlarmControlPanel feature numbers."""
|
||||
|
||||
ARM_HOME = 1
|
||||
ARM_AWAY = 2
|
||||
ARM_NIGHT = 4
|
||||
TRIGGER = 8
|
||||
ARM_CUSTOM_BYPASS = 16
|
||||
ARM_VACATION = 32
|
||||
_FEATURES: dict[
|
||||
ESPHomeAlarmControlPanelEntityFeature, AlarmControlPanelEntityFeature
|
||||
] = {
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_HOME: (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY: (
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT: (
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.TRIGGER: (
|
||||
AlarmControlPanelEntityFeature.TRIGGER
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: (
|
||||
AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION: (
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class EsphomeAlarmControlPanel(
|
||||
@@ -73,20 +85,14 @@ class EsphomeAlarmControlPanel(
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
feature = 0
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_HOME:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
if static_info.supported_features & EspHomeACPFeatures.TRIGGER:
|
||||
feature |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
self._attr_supported_features = AlarmControlPanelEntityFeature(feature)
|
||||
esp_flags = ESPHomeAlarmControlPanelEntityFeature(
|
||||
static_info.supported_features
|
||||
)
|
||||
flags = AlarmControlPanelEntityFeature(0)
|
||||
for esp_flag in esp_flags:
|
||||
if (flag := _FEATURES.get(esp_flag)) is not None:
|
||||
flags |= flag
|
||||
self._attr_supported_features = flags
|
||||
self._attr_code_format = (
|
||||
CodeFormat.NUMBER if static_info.requires_code else None
|
||||
)
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
"""Bluetooth support for esphome."""
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import APIClient, DeviceInfo
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIVersion,
|
||||
BluetoothProxyFeature,
|
||||
BluetoothScannerMode,
|
||||
BluetoothScannerStateResponse,
|
||||
DeviceInfo,
|
||||
)
|
||||
from bleak_esphome import connect_scanner
|
||||
|
||||
from homeassistant.components.bluetooth import async_register_scanner
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
async_register_scanner,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entry_data import RuntimeEntryData
|
||||
from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak_esphome.backend.scanner import ESPHomeScanner
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode}
|
||||
|
||||
|
||||
@hass_callback
|
||||
@@ -23,6 +40,7 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None:
|
||||
@hass_callback
|
||||
def async_connect_scanner(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
entry_data: RuntimeEntryData,
|
||||
cli: APIClient,
|
||||
device_info: DeviceInfo,
|
||||
@@ -35,17 +53,75 @@ def async_connect_scanner(
|
||||
scanner = client_data.scanner
|
||||
if TYPE_CHECKING:
|
||||
assert scanner is not None
|
||||
return partial(
|
||||
_async_unload,
|
||||
[
|
||||
async_register_scanner(
|
||||
hass,
|
||||
scanner,
|
||||
source_domain=DOMAIN,
|
||||
source_model=device_info.model,
|
||||
source_config_entry_id=entry_data.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
scanner.async_setup(),
|
||||
],
|
||||
)
|
||||
api_version = cli.api_version or APIVersion()
|
||||
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version)
|
||||
state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
|
||||
# Pin mode before async_register_scanner so habluetooth spawns the AUTO worker.
|
||||
deferred_migration: CALLBACK_TYPE | None = None
|
||||
if state_and_mode:
|
||||
deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli)
|
||||
callbacks: list[CALLBACK_TYPE] = [
|
||||
async_register_scanner(
|
||||
hass,
|
||||
scanner,
|
||||
source_domain=DOMAIN,
|
||||
source_model=device_info.model,
|
||||
source_config_entry_id=entry_data.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
scanner.async_setup(),
|
||||
]
|
||||
if deferred_migration is not None:
|
||||
callbacks.append(deferred_migration)
|
||||
return partial(_async_unload, callbacks)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def _async_apply_scanning_mode(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
scanner: ESPHomeScanner,
|
||||
cli: APIClient,
|
||||
) -> CALLBACK_TYPE | None:
|
||||
"""Apply saved scanning mode synchronously; migrate from configured_mode later."""
|
||||
saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE)
|
||||
if saved is not None and saved not in _VALID_SCANNING_MODES:
|
||||
_LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved)
|
||||
saved = None
|
||||
initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE
|
||||
scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value))
|
||||
if saved is not None:
|
||||
return None
|
||||
|
||||
unsub_holder: list[CALLBACK_TYPE] = []
|
||||
|
||||
@hass_callback
|
||||
def _migrate(state: BluetoothScannerStateResponse) -> None:
|
||||
# proto3 unset enums decode to None; wait for a real value.
|
||||
if (configured_pb := state.configured_mode) is None:
|
||||
return
|
||||
if unsub_holder:
|
||||
unsub_holder.pop()()
|
||||
if configured_pb is BluetoothScannerMode.PASSIVE:
|
||||
new_mode = BluetoothScanningMode.PASSIVE
|
||||
else:
|
||||
new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={
|
||||
**entry.options,
|
||||
CONF_BLUETOOTH_SCANNING_MODE: new_mode.value,
|
||||
},
|
||||
)
|
||||
# AUTO -> AUTO is already pinned; only re-apply on a downgrade.
|
||||
if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE):
|
||||
scanner.async_set_scanning_mode(new_mode)
|
||||
|
||||
unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate))
|
||||
|
||||
@hass_callback
|
||||
def _unsubscribe() -> None:
|
||||
if unsub_holder:
|
||||
unsub_holder.pop()()
|
||||
|
||||
return _unsubscribe
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, cast
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
BluetoothProxyFeature,
|
||||
DeviceInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
@@ -20,6 +21,7 @@ import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_ESPHOME,
|
||||
SOURCE_IGNORE,
|
||||
@@ -38,6 +40,11 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.importlib import async_import_module
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
@@ -47,10 +54,12 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .const import (
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_BLUETOOTH_SCANNING_MODE,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_NOISE_PSK,
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
DEFAULT_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_BLUETOOTH_SCANNING_MODE,
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
@@ -68,6 +77,18 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
DEFAULT_NAME = "ESPHome"
|
||||
|
||||
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
BluetoothScanningMode.AUTO.value,
|
||||
BluetoothScanningMode.ACTIVE.value,
|
||||
BluetoothScanningMode.PASSIVE.value,
|
||||
],
|
||||
translation_key="bluetooth_scanning_mode",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a esphome config flow."""
|
||||
@@ -936,18 +957,44 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
options = self.config_entry.options
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
default=options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
default=options.get(CONF_SUBSCRIBE_LOGS, False),
|
||||
): bool,
|
||||
}
|
||||
if _entry_has_bluetooth_scanner(self.config_entry):
|
||||
schema[
|
||||
vol.Required(
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
CONF_BLUETOOTH_SCANNING_MODE,
|
||||
default=options.get(
|
||||
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
] = _BLUETOOTH_SCANNING_MODE_SELECTOR
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema))
|
||||
|
||||
|
||||
@callback
|
||||
def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool:
|
||||
"""Return True if the entry exposes a bluetooth proxy scanner or has one saved."""
|
||||
# Keep showing the option if it was previously saved, even when the
|
||||
# device is offline or stops advertising the feature flag, so the
|
||||
# saved value isn't silently dropped on the next options save.
|
||||
if CONF_BLUETOOTH_SCANNING_MODE in entry.options:
|
||||
return True
|
||||
if entry.state is ConfigEntryState.LOADED and (
|
||||
device_info := entry.runtime_data.device_info
|
||||
):
|
||||
flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||
entry.runtime_data.api_version
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
|
||||
return False
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Final
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,13 +19,15 @@ CONF_SUBSCRIBE_LOGS = "subscribe_logs"
|
||||
CONF_DEVICE_NAME = "device_name"
|
||||
CONF_NOISE_PSK = "noise_psk"
|
||||
CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
|
||||
CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode"
|
||||
|
||||
DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.11.0"
|
||||
STABLE_BLE_VERSION_STR = "2026.5.1"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -53,7 +53,7 @@ def async_static_info_updated(
|
||||
platform: entity_platform.EntityPlatform,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
infos: list[EntityInfo],
|
||||
) -> None:
|
||||
@@ -188,7 +188,7 @@ async def platform_async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
*,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
info_filter: Callable[[_InfoT], bool] | None = None,
|
||||
) -> None:
|
||||
@@ -196,6 +196,11 @@ async def platform_async_setup_entry(
|
||||
|
||||
This method is in charge of receiving, distributing and storing
|
||||
info and state updates.
|
||||
|
||||
`entity_type` is any callable that builds an entity from
|
||||
`(entry_data, info, state_type)`. A regular entity class satisfies this,
|
||||
and platforms with multiple entity classes can pass a factory function
|
||||
that picks the class per static info.
|
||||
"""
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.info[info_type] = {}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from functools import partial
|
||||
import functools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEmitterEntity,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .entry_data import RuntimeEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
|
||||
"""Common base for ESPHome infrared entities."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
@@ -32,6 +38,10 @@ class EsphomeInfraredEntity(
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
@@ -46,10 +56,77 @@ class EsphomeInfraredEntity(
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
|
||||
"""ESPHome infrared receiver entity using native API."""
|
||||
|
||||
_unsub_receive: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks including IR receive subscription."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_subscribe_receive()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from the device on entity removal."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._unsub_receive is not None:
|
||||
self._unsub_receive()
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _async_subscribe_receive(self) -> None:
|
||||
"""Subscribe to IR receive events if the device is connected."""
|
||||
# Subscribing requires an active API connection; defer to
|
||||
# _on_device_update when the device is not (yet) available.
|
||||
if self._unsub_receive is not None or not self._entry_data.available:
|
||||
return
|
||||
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
|
||||
self._on_infrared_rf_receive
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self._async_subscribe_receive()
|
||||
elif self._unsub_receive is not None:
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
|
||||
"""Handle a received IR signal from the device."""
|
||||
if (
|
||||
event.key != self._static_info.key
|
||||
or event.device_id != self._static_info.device_id
|
||||
):
|
||||
return
|
||||
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
|
||||
|
||||
|
||||
def _make_infrared_entity(
|
||||
entry_data: RuntimeEntryData,
|
||||
info: EntityInfo,
|
||||
state_type: type[EntityState],
|
||||
) -> _EsphomeInfraredEntity:
|
||||
"""Build the right infrared entity based on the InfraredInfo capabilities."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(info, InfraredInfo)
|
||||
cls = (
|
||||
EsphomeInfraredReceiverEntity
|
||||
if info.capabilities & InfraredCapability.RECEIVER
|
||||
else EsphomeInfraredEmitterEntity
|
||||
)
|
||||
return cls(entry_data, info, state_type)
|
||||
|
||||
|
||||
async_setup_entry = functools.partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
entity_type=_make_infrared_entity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities
|
||||
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -669,7 +669,7 @@ class ESPHomeManager:
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||
entry_data.disconnect_callbacks.add(
|
||||
async_connect_scanner(
|
||||
hass, entry_data, cli, device_info, self.device_id
|
||||
hass, self.entry, entry_data, cli, device_info, self.device_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.1.0",
|
||||
"aioesphomeapi==45.2.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.8.1"
|
||||
"bleak-esphome==3.9.1"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -209,13 +209,24 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
|
||||
"bluetooth_scanning_mode": "Bluetooth scanning mode",
|
||||
"subscribe_logs": "Subscribe to logs from the device."
|
||||
},
|
||||
"data_description": {
|
||||
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
|
||||
"bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.",
|
||||
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"bluetooth_scanning_mode": {
|
||||
"options": {
|
||||
"active": "Active (uses more device battery, fastest updates)",
|
||||
"auto": "Auto (recommended, saves device battery)",
|
||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from eufylife_ble_client import MODEL_TO_NAME
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
@@ -75,6 +76,7 @@ class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={CONF_MODEL: model},
|
||||
)
|
||||
|
||||
await bluetooth.async_request_active_scan(self.hass)
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyfireservicerota import (
|
||||
ExpiredTokenError,
|
||||
@@ -177,10 +178,12 @@ class FireServiceRotaClient:
|
||||
|
||||
if await self.oauth.async_refresh_tokens():
|
||||
self.token_refresh_failure = False
|
||||
await self._hass.async_add_executor_job(self.websocket.start_listener)
|
||||
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
return await self._hass.async_add_executor_job(func, *args)
|
||||
def _restart_and_call() -> Any:
|
||||
self.websocket.start_listener()
|
||||
return func(*args)
|
||||
|
||||
return await self._hass.async_add_executor_job(_restart_and_call)
|
||||
|
||||
async def async_update(self) -> dict | None:
|
||||
"""Get the latest availability data."""
|
||||
|
||||
@@ -5,11 +5,10 @@ import logging
|
||||
from fishaudio import AsyncFishAudio
|
||||
from fishaudio.exceptions import AuthenticationError, FishAudioError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_API_KEY
|
||||
from .types import FishAudioConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
LanguageSelector,
|
||||
@@ -29,11 +30,8 @@ from homeassistant.helpers.selector import (
|
||||
from .const import (
|
||||
API_KEYS_URL,
|
||||
BACKEND_MODELS,
|
||||
CONF_API_KEY,
|
||||
CONF_BACKEND,
|
||||
CONF_LANGUAGE,
|
||||
CONF_LATENCY,
|
||||
CONF_NAME,
|
||||
CONF_SELF_ONLY,
|
||||
CONF_SORT_BY,
|
||||
CONF_TITLE,
|
||||
|
||||
@@ -4,17 +4,10 @@ from typing import Literal
|
||||
|
||||
DOMAIN = "fish_audio"
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_NAME: Literal["name"] = "name"
|
||||
CONF_USER_ID: Literal["user_id"] = "user_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_API_KEY: Literal["api_key"] = "api_key"
|
||||
CONF_VOICE_ID: Literal["voice_id"] = "voice_id"
|
||||
CONF_BACKEND: Literal["backend"] = "backend"
|
||||
CONF_SELF_ONLY: Literal["self_only"] = "self_only"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_LANGUAGE: Literal["language"] = "language"
|
||||
CONF_SORT_BY: Literal["sort_by"] = "sort_by"
|
||||
CONF_LATENCY: Literal["latency"] = "latency"
|
||||
CONF_TITLE: Literal["title"] = "title"
|
||||
|
||||
@@ -13,8 +13,6 @@ ATTR_LAST_SAVED_AT: Final = "last_saved_at"
|
||||
|
||||
ATTR_DURATION: Final = "duration"
|
||||
ATTR_DISTANCE: Final = "distance"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ELEVATION: Final = "elevation"
|
||||
ATTR_HEIGHT: Final = "height"
|
||||
ATTR_WEIGHT: Final = "weight"
|
||||
ATTR_BODY: Final = "body"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user