mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
348 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c86d7052c0 | |||
| 07965e468b | |||
| 5972dc182b | |||
| 7ad535841a | |||
| d9fae7fecf | |||
| 2f3f91ec82 | |||
| f6e8394771 | |||
| 27b0ba1a25 | |||
| 1070226acf | |||
| e8d7df7770 | |||
| 31f87b3a8a | |||
| 81efe6ddbf | |||
| af53865b2a | |||
| beeb8aca4c | |||
| b4063aaac9 | |||
| 7087cb2046 | |||
| 0044c43f3a | |||
| 0bb6113bfd | |||
| b6fa89c032 | |||
| 6a18e05bda | |||
| b312bd010b | |||
| 3487eaf8c5 | |||
| 9db7b3d012 | |||
| 23ecc311fd | |||
| 3355581bbf | |||
| 995707160f | |||
| b3199bac88 | |||
| 97de25d55a | |||
| 16ef7f967e | |||
| 4864a4125e | |||
| e2b71cee1f | |||
| 5bd92d47a9 | |||
| 7f0133e2ce | |||
| ba1ed66f7a | |||
| 2bc91e7a3e | |||
| 1c3a080506 | |||
| 5ecbfea028 | |||
| 9b67a24d92 | |||
| 4bd829a6a8 | |||
| 6feeba1f4f | |||
| 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 | |||
| 0723d8d83f | |||
| 73c9edd3e8 | |||
| 18f30bd97b | |||
| eae6e79b61 | |||
| 5bb42801d9 | |||
| 98271265d3 | |||
| 92d20477bc | |||
| 9352a0057e | |||
| 5fb874277a | |||
| d65f605398 | |||
| 7e5b448f70 | |||
| ef5da5ef36 | |||
| 410f00c4ed | |||
| 33c205dc04 | |||
| 267b3e279d | |||
| 9c1cd8093d | |||
| 201c0c2470 | |||
| 281d6e0e8b | |||
| 88746534a4 | |||
| 135f91c3c5 | |||
| 49d8dc88d9 | |||
| a7a2c1eb02 | |||
| 6596f956d2 | |||
| 9d8859833b | |||
| 65a4c10660 | |||
| 1737b50558 | |||
| 614c7006f6 | |||
| 8c901cc405 | |||
| 5d0fdfd38b | |||
| c9ed57bc56 | |||
| 0e0901993d | |||
| 54aba11091 | |||
| dc9116a7a7 | |||
| 1e90882918 | |||
| e8295e14b1 | |||
| 7ebaaf129a | |||
| ee734dede6 | |||
| ebc582c813 | |||
| 311e5a9bd2 | |||
| cd6c3c878b | |||
| 51589ec2ff | |||
| 8e1a04dc82 | |||
| 6b15f9a2ec | |||
| 8d66752556 | |||
| 266767e37d | |||
| d39775ac34 | |||
| a314f7bf64 | |||
| 37478d33eb | |||
| 5a76f3bd19 | |||
| 17e105083e | |||
| db8589b2bc | |||
| 771b016f33 | |||
| 0bc0745e8c | |||
| ea084797d3 | |||
| 2456753caf | |||
| 070de13c14 | |||
| 5e45f37ee6 | |||
| 4a96880f51 | |||
| 228ac01124 | |||
| d366027e6b | |||
| 2f35ad2a8a | |||
| 95cc9aed64 | |||
| 37d6449a49 | |||
| 249b5435d9 | |||
| 3293ebcea5 | |||
| 47d8adc77c | |||
| 356e6a691b | |||
| b26c2f3854 | |||
| 0830988687 | |||
| 456202325a | |||
| 1e47149764 | |||
| 116b63ca3a | |||
| 3096bcf8a9 | |||
| a4027029d0 | |||
| fffc9d0695 | |||
| 3ca5cf5add | |||
| 087cb77042 | |||
| 8bd1c07ec9 | |||
| 9ecb59590b | |||
| e14eb9fbc5 | |||
| 149c796227 | |||
| 3383e5b1e9 | |||
| 05862c6dc8 | |||
| b35ac41470 | |||
| 20cec56512 | |||
| 74580262b6 | |||
| f75cdae602 | |||
| 8c95f4f7ae | |||
| c3ec51c471 | |||
| 0f80a4bc18 | |||
| 0761d618f1 | |||
| 03e3c46faf | |||
| d1962b0df2 | |||
| 7a38a2303a | |||
| 6f5c2a8614 | |||
| ff36498698 | |||
| 23e19ea2e4 | |||
| c33f174041 | |||
| bbe64d74e3 | |||
| ed3a71f2ee | |||
| 46c49daba4 | |||
| a2f2ded188 | |||
| 7be061796d | |||
| 27c7d8de0c | |||
| 07542523b5 | |||
| 18597bb653 | |||
| c4be57a294 | |||
| 7ceaebb086 | |||
| 7c5ef09734 | |||
| b4d8ba66fe | |||
| 308221ce67 | |||
| 1344213335 | |||
| 7e405e9014 | |||
| b0c45132ed | |||
| 7d7738303a | |||
| dd0cdc4fc4 | |||
| 18ea40c46d | |||
| a23131efc8 | |||
| 4940a0abae | |||
| 5f98d5ae52 | |||
| ba18cded30 | |||
| fb7504e9df | |||
| 106f815a1e | |||
| 167757762b | |||
| 3a902e1a16 | |||
| 85c11672d8 | |||
| 89649df20d | |||
| 7b749b95ce | |||
| cc140be85c | |||
| e1ad765414 | |||
| 44b1fea745 | |||
| 5dd04363b2 | |||
| 03aa979309 | |||
| 6fabbb354b | |||
| f644448d0f | |||
| 4e61581cd8 | |||
| 6f87d02b72 | |||
| 348f6149b4 | |||
| a4227ef1bc | |||
| aac49a567f | |||
| 76b878b136 | |||
| 2d05931683 | |||
| b10582b0a9 | |||
| b193d951d7 | |||
| 4cd0d9dcec | |||
| 32f65b2e11 | |||
| 8c79d1e44b | |||
| 8d53f7a520 | |||
| cc83ee88fb | |||
| 0c5b02eff3 | |||
| 9da9f8fd50 | |||
| d70ffcd3e9 | |||
| 3e26d0dfe3 | |||
| eab9747b32 | |||
| 9e955d8294 | |||
| f08cd01ff8 | |||
| eabaf3b0fe | |||
| 65ca790d15 | |||
| d177944f7a | |||
| 7f186f4430 | |||
| 4f4f4642a7 | |||
| 12e443cd31 | |||
| 22a7daabe7 | |||
| c139e99abd | |||
| 2bfdb96a3f | |||
| 4b24ca924b | |||
| 1d3d714e4f | |||
| ffae6eda8a | |||
| 4dd996b728 | |||
| afad1e8dac | |||
| 8e41933251 | |||
| c581eaad53 | |||
| 3050e79d06 | |||
| 0e8ecd1065 | |||
| 94732139f4 | |||
| c5e08b2409 | |||
| c12e1b5f4a | |||
| 6cfedb55e6 | |||
| af4cb9530b | |||
| 58e97e7d5f | |||
| 2945b51617 | |||
| 9d0e2df627 | |||
| 643ae080db | |||
| a7eaa51179 | |||
| e15852ff38 | |||
| f6dec34136 | |||
| 53905fbc49 | |||
| 8218ff0fe8 | |||
| 663f7e3e6b | |||
| 4dfa2b8b88 | |||
| f828b165b1 | |||
| c56c506648 | |||
| 8e5bf2a35f | |||
| 4d575e69a4 | |||
| 4f78bbccc0 | |||
| 2d66ebe54a | |||
| a3e1209778 | |||
| 7c44a0b88d | |||
| 126058e0fa | |||
| 28742822cb | |||
| 179d370c2a | |||
| 2d8f3691cf | |||
| ce4fc9e880 | |||
| 9e357e7e5a | |||
| ed35b23e62 | |||
| 191d2d1f12 | |||
| b165d8251f | |||
| 5e8886aeb7 | |||
| bdb66635f8 | |||
| 5ba6e348da | |||
| ed52b0ce80 | |||
| 33ee3d6967 | |||
| f36676c32c | |||
| 77beddb1e7 | |||
| 1677e410b3 | |||
| 1be09347cd | |||
| c30ac2c0f3 | |||
| 145c7435a5 | |||
| 60f3b3bcc0 | |||
| 03e6d3bd30 | |||
| ee4d150e13 | |||
| 148603a10e | |||
| 1dbd933d3c | |||
| f7ee7423fe | |||
| 6322f1e37a | |||
| 0d8c7fbb9d | |||
| 70e30b02a4 | |||
| ebd21ea9b2 | |||
| 9aa092cd34 | |||
| b274fe85b7 | |||
| 777c36998c | |||
| a3977428f9 | |||
| 2d626c263c | |||
| d1461f2e68 | |||
| 3b778d2cc7 | |||
| 67b7d17a2f | |||
| 1afeadc342 | |||
| f6aa4e2092 | |||
| 3b00c5bb96 | |||
| ef7eed579b | |||
| 568a0085fe |
@@ -55,11 +55,11 @@ jobs:
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
@@ -565,6 +565,7 @@ homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.teleinfo.*
|
||||
homeassistant.components.teltonika.*
|
||||
homeassistant.components.teslemetry.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
|
||||
Generated
+2
@@ -2056,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
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Alexa Devices integration."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
@@ -12,6 +16,7 @@ from .services import async_setup_services
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
@@ -34,6 +39,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.sync_history_state()
|
||||
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
async def _cancel_http2() -> None:
|
||||
http2_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await http2_task
|
||||
|
||||
alexa_httpx_client = httpx_client.get_async_client(
|
||||
hass,
|
||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
||||
)
|
||||
|
||||
http2_task = await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
|
||||
)
|
||||
|
||||
entry.async_on_unload(_cancel_http2)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -8,13 +8,13 @@ from aioamazondevices.exceptions import (
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -73,6 +73,11 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
if routine.domain == Platform.BUTTON
|
||||
}
|
||||
|
||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||
|
||||
self.api.on_history_event.append(self.history_state_event_handler)
|
||||
self.api.on_history_event.freeze()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
@@ -149,3 +154,38 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
)
|
||||
if entity_id:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async def sync_history_state(self) -> None:
|
||||
"""Sync history state."""
|
||||
try:
|
||||
self._vocal_records = await self.api.sync_history_state()
|
||||
except CannotAuthenticate as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except BaseException as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
|
||||
async def history_state_event_handler(
|
||||
self, vocal_records: dict[str, AmazonVocalRecord]
|
||||
) -> None:
|
||||
"""Handle pushed vocal record events."""
|
||||
self._vocal_records = {**self._vocal_records, **vocal_records}
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||
"""Vocal records of devices."""
|
||||
return self._vocal_records
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Support for events."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.event import EventEntity, EventEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENTS: Final = {
|
||||
EventEntityDescription(
|
||||
key="voice_event",
|
||||
translation_key="voice_event",
|
||||
),
|
||||
}
|
||||
|
||||
EVENT_TYPE = "triggered"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices events based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AlexaVoiceEvent(coordinator, serial_num, event_desc)
|
||||
for event_desc in EVENTS
|
||||
for serial_num in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
"""Representation of an Alexa voice event."""
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int | None = None
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
if not (
|
||||
vocal_record := self.coordinator.vocal_records.get(
|
||||
self.device.serial_number
|
||||
)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"No vocal record found for device %s [%s]",
|
||||
self.device.account_name,
|
||||
self.device.serial_number,
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
self._trigger_event(
|
||||
EVENT_TYPE,
|
||||
{
|
||||
"intent": vocal_record.intent,
|
||||
"voice_command": vocal_record.title,
|
||||
"voice_reply": vocal_record.sub_title,
|
||||
},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"default": "mdi:chat-processing"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"voc_index": {
|
||||
"default": "mdi:molecule"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
"requirements": ["aioamazondevices==13.8.0"]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"name": "Voice event",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"triggered": "Triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"announce": {
|
||||
"name": "Announce"
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,26 +106,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
entry.async_on_unload(
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
entry.async_on_unload(async_at_started(hass, start_schedule))
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an Analytics config entry."""
|
||||
analytics = hass.data.pop(DATA_COMPONENT)
|
||||
analytics.cancel_scheduled()
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
|
||||
@@ -109,7 +139,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +162,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,10 +345,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
@@ -630,6 +626,16 @@ class Analytics:
|
||||
err,
|
||||
)
|
||||
|
||||
@callback
|
||||
def cancel_scheduled(self) -> None:
|
||||
"""Cancel all scheduled analytics tasks."""
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled is not None:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -14,5 +15,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -22,6 +22,7 @@ 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,
|
||||
@@ -40,7 +41,6 @@ from .const import (
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -22,9 +22,6 @@ 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,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.11",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5,3 +5,5 @@ ATTR_URL = "color_extract_url"
|
||||
|
||||
DOMAIN = "color_extractor"
|
||||
DEFAULT_NAME = "Color extractor"
|
||||
|
||||
SERVICE_GET_COLOR = "get_color"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"get_color": {
|
||||
"service": "mdi:select-color"
|
||||
},
|
||||
"turn_on": {
|
||||
"service": "mdi:lightbulb-on"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from colorthief import ColorThief
|
||||
@@ -15,15 +16,16 @@ from homeassistant.components.light import (
|
||||
LIGHT_TURN_ON_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Extend the existing light.turn_on service schema
|
||||
SERVICE_SCHEMA = vol.All(
|
||||
TURN_ON_SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
@@ -34,6 +36,14 @@ SERVICE_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
GET_COLOR_SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
{
|
||||
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
|
||||
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _get_file(file_path: str) -> str:
|
||||
"""Get a PIL acceptable input file reference.
|
||||
@@ -145,6 +155,50 @@ async def async_handle_service(service_call: ServiceCall) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def async_handle_get_color(
|
||||
service_call: ServiceCall,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle get_color service call."""
|
||||
service_data = dict(service_call.data)
|
||||
|
||||
try:
|
||||
if ATTR_URL in service_data:
|
||||
image_type = "URL"
|
||||
image_reference = service_data.pop(ATTR_URL)
|
||||
color = await _async_extract_color_from_url(
|
||||
service_call.hass, image_reference
|
||||
)
|
||||
|
||||
elif ATTR_PATH in service_data:
|
||||
image_type = "file path"
|
||||
image_reference = service_data.pop(ATTR_PATH)
|
||||
color = await service_call.hass.async_add_executor_job(
|
||||
_extract_color_from_path, service_call.hass, image_reference
|
||||
)
|
||||
|
||||
except UnidentifiedImageError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_image",
|
||||
translation_placeholders={
|
||||
"image_type": image_type,
|
||||
"image_reference": image_reference,
|
||||
},
|
||||
) from ex
|
||||
|
||||
if color is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_image",
|
||||
translation_placeholders={
|
||||
"image_type": image_type,
|
||||
"image_reference": image_reference,
|
||||
},
|
||||
)
|
||||
|
||||
return {"color": color}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the services."""
|
||||
@@ -153,5 +207,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
async_handle_service,
|
||||
schema=SERVICE_SCHEMA,
|
||||
schema=TURN_ON_SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_COLOR,
|
||||
async_handle_get_color,
|
||||
schema=GET_COLOR_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -11,3 +11,13 @@ turn_on:
|
||||
example: /opt/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
get_color:
|
||||
fields:
|
||||
color_extract_url:
|
||||
example: https://www.example.com/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
color_extract_path:
|
||||
example: /opt/images/logo.png
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -6,7 +6,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_image": {
|
||||
"message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_color": {
|
||||
"description": "Gets the predominant RGB color found in the image provided by URL or file path.",
|
||||
"fields": {
|
||||
"color_extract_path": {
|
||||
"description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs.",
|
||||
"name": "[%key:common::config_flow::data::path%]"
|
||||
},
|
||||
"color_extract_url": {
|
||||
"description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls.",
|
||||
"name": "[%key:common::config_flow::data::url%]"
|
||||
}
|
||||
},
|
||||
"name": "Get predominant color"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.",
|
||||
"fields": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,520 +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.components.zone import ATTR_PASSIVE, ATTR_RADIUS
|
||||
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_ = {
|
||||
"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
|
||||
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
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.3",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +44,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
box_name, _ = await self._validate_input(discovery_info.ip)
|
||||
except UnsupportedBoardError:
|
||||
_LOGGER.debug(
|
||||
"Unsupported Duco board discovered via DHCP at %s", discovery_info.ip
|
||||
)
|
||||
return self.async_abort(reason="unsupported_board")
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
@@ -61,6 +67,12 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle zeroconf discovery."""
|
||||
try:
|
||||
box_name, mac = await self._validate_input(discovery_info.host)
|
||||
except UnsupportedBoardError:
|
||||
_LOGGER.debug(
|
||||
"Unsupported Duco board discovered via zeroconf at %s",
|
||||
discovery_info.host,
|
||||
)
|
||||
return self.async_abort(reason="unsupported_board")
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
@@ -102,6 +114,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||
except UnsupportedBoardError:
|
||||
errors["base"] = "unsupported_board"
|
||||
except DucoConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except DucoError:
|
||||
@@ -133,6 +147,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||
except UnsupportedBoardError:
|
||||
errors["base"] = "unsupported_board"
|
||||
except DucoConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except DucoError:
|
||||
@@ -162,6 +178,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
board_info = await async_get_supported_board_info(client)
|
||||
lan_info = await client.async_get_lan_info()
|
||||
return board_info.box_name, lan_info.mac
|
||||
|
||||
@@ -4,7 +4,11 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.exceptions import (
|
||||
DucoConnectionError,
|
||||
DucoError,
|
||||
DucoResponseError,
|
||||
)
|
||||
from duco_connectivity.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -13,6 +17,7 @@ from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .validation import UnsupportedBoardError, async_get_supported_board_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +57,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch board info once during initial setup."""
|
||||
try:
|
||||
self.board_info = await self.client.async_get_board_info()
|
||||
self.board_info = await async_get_supported_board_info(self.client)
|
||||
except UnsupportedBoardError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_board",
|
||||
) from err
|
||||
except DucoResponseError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -70,20 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
@@ -98,6 +100,9 @@
|
||||
},
|
||||
"rate_limit_exceeded": {
|
||||
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
|
||||
},
|
||||
"unsupported_board": {
|
||||
"message": "[%key:component::duco::config::abort::unsupported_board%]"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Validation helpers for supported Duco systems."""
|
||||
|
||||
from awesomeversion import (
|
||||
AwesomeVersion,
|
||||
AwesomeVersionStrategy,
|
||||
AwesomeVersionStrategyException,
|
||||
)
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoResponseError
|
||||
from duco_connectivity.models import BoardInfo
|
||||
|
||||
# Newer Connectivity boards expose /info with PublicApiVersion. We use that
|
||||
# endpoint to distinguish supported Connectivity hardware from older
|
||||
# Communication board V1 hardware.
|
||||
_MIN_PUBLIC_API_VERSION = AwesomeVersion(
|
||||
"2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedBoardError(Exception):
|
||||
"""Raised when the Duco system is not supported by this integration."""
|
||||
|
||||
|
||||
def validate_board_support(board_info: BoardInfo) -> None:
|
||||
"""Raise UnsupportedBoardError if the board does not meet support requirements."""
|
||||
version = board_info.public_api_version
|
||||
if version is None:
|
||||
raise UnsupportedBoardError("Board did not report a public API version")
|
||||
try:
|
||||
parsed_version = AwesomeVersion(
|
||||
version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
except AwesomeVersionStrategyException as err:
|
||||
raise UnsupportedBoardError(
|
||||
f"Board reported malformed public API version: {version}"
|
||||
) from err
|
||||
if parsed_version < _MIN_PUBLIC_API_VERSION:
|
||||
raise UnsupportedBoardError(
|
||||
"Board public API version "
|
||||
f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}"
|
||||
)
|
||||
|
||||
|
||||
async def async_get_supported_board_info(client: DucoClient) -> BoardInfo:
|
||||
"""Fetch and validate board info for a supported Duco system."""
|
||||
try:
|
||||
board_info = await client.async_get_board_info()
|
||||
except DucoResponseError as err:
|
||||
if err.status == 404:
|
||||
# Duco indicated that Communication board V1 does not implement
|
||||
# /info, so a 404 is enough to treat the device as unsupported.
|
||||
raise UnsupportedBoardError(
|
||||
"Board does not expose the /info endpoint"
|
||||
) from err
|
||||
raise
|
||||
|
||||
validate_board_support(board_info)
|
||||
return board_info
|
||||
@@ -8,12 +8,16 @@ from pyecobee import (
|
||||
ECOBEE_REFRESH_TOKEN,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS
|
||||
@@ -102,7 +106,26 @@ class EcobeeData:
|
||||
async def refresh(self) -> bool:
|
||||
"""Refresh ecobee tokens and update config entry."""
|
||||
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
|
||||
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
|
||||
try:
|
||||
success = await self._hass.async_add_executor_job(
|
||||
self.ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"ecobee account requires MFA; reauthentication needed"
|
||||
) from err
|
||||
except EcobeeAuthFailedError as err:
|
||||
if self.ecobee.config.get(ECOBEE_USERNAME):
|
||||
raise ConfigEntryAuthFailed(
|
||||
"ecobee rejected stored credentials"
|
||||
) from err
|
||||
_LOGGER.error("Ecobee rejected stored credentials: %s", err)
|
||||
return False
|
||||
except EcobeeAuthUnknownError:
|
||||
_LOGGER.exception("Unexpected error refreshing ecobee tokens")
|
||||
return False
|
||||
|
||||
if success:
|
||||
data = {}
|
||||
if self.ecobee.config.get(ECOBEE_API_KEY):
|
||||
data = {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Config flow to configure ecobee."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
|
||||
from pyecobee import (
|
||||
ECOBEE_API_KEY,
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
MfaChallenge,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
|
||||
@@ -18,6 +28,9 @@ _USER_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
_MFA_SCHEMA = vol.Schema({vol.Required(CONF_CODE): str})
|
||||
_REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||
|
||||
|
||||
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle an ecobee config flow."""
|
||||
@@ -25,12 +38,15 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
_ecobee: Ecobee
|
||||
_mfa_challenge: MfaChallenge | None = None
|
||||
_pending_username: str | None = None
|
||||
_pending_password: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
@@ -38,27 +54,34 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
if api_key and not (username or password):
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
elif username and password and not api_key:
|
||||
self._pending_username = username
|
||||
self._pending_password = password
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
}
|
||||
)
|
||||
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
|
||||
config = {
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=DOMAIN, data=config)
|
||||
errors["base"] = "login_failed"
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
self._mfa_challenge = err.args[0]
|
||||
return await self.async_step_mfa()
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "login_failed"
|
||||
else:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -68,16 +91,46 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Collect an MFA OTP code and complete the login."""
|
||||
assert self._mfa_challenge is not None
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
code = user_input[CONF_CODE].strip()
|
||||
if not code:
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
else:
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.submit_mfa_code, self._mfa_challenge, code
|
||||
)
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=_MFA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"mfa_type": self._mfa_challenge.mfa_type},
|
||||
)
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Present the user with the PIN to authorize on ecobee.com."""
|
||||
errors = {}
|
||||
"""Present the user with the PIN so that the app can be authorized on ecobee.com."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Attempt to obtain tokens from ecobee and finish the flow.
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_tokens):
|
||||
# Refresh token obtained; create the config entry.
|
||||
config = {
|
||||
CONF_API_KEY: self._ecobee.api_key,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
@@ -93,3 +146,61 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an ecobee authentication error."""
|
||||
self._pending_username = entry_data.get(CONF_USERNAME)
|
||||
self._pending_password = entry_data.get(CONF_PASSWORD)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Re-run the web login. May surface a fresh MFA challenge."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._pending_password = user_input[CONF_PASSWORD]
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: self._pending_username,
|
||||
ECOBEE_PASSWORD: self._pending_password,
|
||||
}
|
||||
)
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
self._mfa_challenge = err.args[0]
|
||||
return await self.async_step_mfa()
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "login_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=_REAUTH_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"username": self._pending_username or ""},
|
||||
)
|
||||
|
||||
def _async_create_or_update_entry(self) -> ConfigFlowResult:
|
||||
"""Create a new entry or update the existing one on reauth."""
|
||||
data = {
|
||||
CONF_USERNAME: self._pending_username,
|
||||
CONF_PASSWORD: self._pending_password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
return self.async_create_entry(title=DOMAIN, data=data)
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_mfa_code": "The MFA code was not accepted by ecobee; please try again.",
|
||||
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
|
||||
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again."
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"authorize": {
|
||||
"description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**."
|
||||
},
|
||||
"mfa": {
|
||||
"data": {
|
||||
"code": "MFA code"
|
||||
},
|
||||
"description": "ecobee requires multi-factor authentication. Enter the {mfa_type} code from your authenticator app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Reauthenticate the ecobee account for **{username}**."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,24 +4,14 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime, ProductType
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
DeviceUnavailable,
|
||||
GardenaBluetoothConfigEntry,
|
||||
@@ -39,7 +29,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.VALVE,
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
TIMEOUT = 20.0
|
||||
DISCONNECT_DELAY = 5
|
||||
|
||||
|
||||
@@ -57,15 +46,6 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
@@ -73,49 +53,30 @@ async def async_setup_entry(
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
) from exception
|
||||
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=name,
|
||||
sw_version=sw_version,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
hass,
|
||||
entry,
|
||||
LOGGER,
|
||||
client,
|
||||
address,
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
return True
|
||||
|
||||
|
||||
@@ -123,7 +84,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.async_shutdown()
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -4,17 +4,28 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
GardenaBluetoothException,
|
||||
)
|
||||
from gardena_bluetooth.parse import Characteristic, CharacteristicType
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicTime,
|
||||
CharacteristicType,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,8 +48,6 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
config_entry: GardenaBluetoothConfigEntry,
|
||||
logger: logging.Logger,
|
||||
client: Client,
|
||||
characteristics: set[str],
|
||||
device_info: DeviceInfo,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
@@ -52,14 +61,63 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
self.address = address
|
||||
self.data = {}
|
||||
self.client = client
|
||||
self.characteristics = characteristics
|
||||
self.device_info = device_info
|
||||
self.characteristics: set[str] = set()
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=config_entry.title,
|
||||
)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shutdown coordinator and any connection."""
|
||||
await super().async_shutdown()
|
||||
await self.client.disconnect()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator and read initial device metadata."""
|
||||
try:
|
||||
chars = await self.client.get_all_characteristics()
|
||||
|
||||
sw_version = await self.client.read_char(
|
||||
DeviceInformation.firmware_version, None
|
||||
)
|
||||
manufacturer = await self.client.read_char(
|
||||
DeviceInformation.manufacturer_name, None
|
||||
)
|
||||
model = await self.client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = self.config_entry.title
|
||||
name = await self.client.read_char(
|
||||
DeviceConfiguration.custom_device_name, name
|
||||
)
|
||||
name = await self.client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await self._update_timestamp(DeviceConfiguration.unix_timestamp)
|
||||
await self._update_timestamp(AquaContour.unix_timestamp)
|
||||
|
||||
self.characteristics = set(chars.keys())
|
||||
self.device_info = DeviceInfo(
|
||||
{
|
||||
**self.device_info,
|
||||
"name": name,
|
||||
"sw_version": sw_version,
|
||||
"manufacturer": manufacturer,
|
||||
"model": model,
|
||||
}
|
||||
)
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
raise UpdateFailed(
|
||||
f"Unable to set up Gardena Bluetooth device due to {exception}"
|
||||
) from exception
|
||||
|
||||
async def _update_timestamp(self, char: CharacteristicTime) -> None:
|
||||
try:
|
||||
await self.client.update_timestamp(char, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
async def _async_update_data(self) -> dict[str, bytes]:
|
||||
"""Poll the device."""
|
||||
uuids: set[str] = {
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"free_members": {
|
||||
"default": "mdi:account-outline"
|
||||
},
|
||||
"gift_members": {
|
||||
"default": "mdi:gift-outline"
|
||||
},
|
||||
"latest_email": {
|
||||
"default": "mdi:email-newsletter"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,12 @@ SENSORS: tuple[GhostSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value_fn=lambda data: data.members.get("comped", 0),
|
||||
),
|
||||
GhostSensorEntityDescription(
|
||||
key="gift_members",
|
||||
translation_key="gift_members",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value_fn=lambda data: data.members.get("gift", 0),
|
||||
),
|
||||
# Post metrics
|
||||
GhostSensorEntityDescription(
|
||||
key="published_posts",
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
"free_members": {
|
||||
"name": "Free members"
|
||||
},
|
||||
"gift_members": {
|
||||
"name": "Gift members"
|
||||
},
|
||||
"latest_email": {
|
||||
"name": "Latest email"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.4"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not errors and login is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_API_KEY: login.apiToken},
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
|
||||
)
|
||||
else:
|
||||
@@ -309,7 +309,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
|
||||
@@ -303,7 +303,7 @@ async def _cast_skill(call: ServiceCall) -> ServiceResponse:
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return asdict(response.data)
|
||||
return asdict(response.data) if call.return_response is True else None
|
||||
|
||||
|
||||
async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
@@ -353,7 +353,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
else:
|
||||
return asdict(response.data)
|
||||
return asdict(response.data) if call.return_response is True else None
|
||||
|
||||
|
||||
async def _score_task(call: ServiceCall) -> ServiceResponse:
|
||||
@@ -418,7 +418,7 @@ async def _score_task(call: ServiceCall) -> ServiceResponse:
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return asdict(response.data)
|
||||
return asdict(response.data) if call.return_response is True else None
|
||||
|
||||
|
||||
async def _transformation(call: ServiceCall) -> ServiceResponse:
|
||||
@@ -503,7 +503,7 @@ async def _transformation(call: ServiceCall) -> ServiceResponse:
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
else:
|
||||
return asdict(response.data)
|
||||
return asdict(response.data) if call.return_response is True else None
|
||||
|
||||
|
||||
async def _get_tasks(call: ServiceCall) -> ServiceResponse:
|
||||
@@ -839,7 +839,11 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
else:
|
||||
return response.data.to_dict(omit_none=True)
|
||||
return (
|
||||
response.data.to_dict(omit_none=True)
|
||||
if call.return_response is True
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -859,7 +863,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service_name,
|
||||
_manage_quests,
|
||||
schema=SERVICE_MANAGE_QUEST_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
for service_name in (
|
||||
@@ -873,7 +877,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service_name,
|
||||
_create_or_update_task,
|
||||
schema=SERVICE_UPDATE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
for service_name in (
|
||||
SERVICE_CREATE_DAILY,
|
||||
@@ -886,7 +890,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service_name,
|
||||
_create_or_update_task,
|
||||
schema=SERVICE_CREATE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -894,7 +898,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_CAST_SKILL,
|
||||
_cast_skill,
|
||||
schema=SERVICE_CAST_SKILL_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -902,14 +906,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_SCORE_HABIT,
|
||||
_score_task,
|
||||
schema=SERVICE_SCORE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SCORE_REWARD,
|
||||
_score_task,
|
||||
schema=SERVICE_SCORE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -917,7 +921,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_TRANSFORMATION,
|
||||
_transformation,
|
||||
schema=SERVICE_TRANSFORMATION_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -562,6 +562,7 @@
|
||||
"info": {
|
||||
"agent_version": "Agent version",
|
||||
"board": "Board",
|
||||
"disk_life_time": "Disk lifetime",
|
||||
"disk_total": "Disk total",
|
||||
"disk_used": "Disk used",
|
||||
"docker_version": "Docker version",
|
||||
|
||||
@@ -99,6 +99,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
os_info = get_os_info(hass)
|
||||
information["board"] = os_info.get("board")
|
||||
|
||||
if (disk_life_time := host_info.get("disk_life_time")) is not None:
|
||||
information["disk_life_time"] = f"{disk_life_time:.0f} %"
|
||||
|
||||
# Not using aiohasupervisor for ping call below intentionally. Given system health
|
||||
# context, it seems preferable to do this check with minimal dependencies
|
||||
information["supervisor_api"] = system_health.async_check_can_reach_url(
|
||||
|
||||
@@ -64,10 +64,6 @@ BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = {
|
||||
key="tamper_detection",
|
||||
device_class=BinarySensorDeviceClass.TAMPER,
|
||||
),
|
||||
"Shelter Alarm": BinarySensorEntityDescription(
|
||||
key="shelter_alarm",
|
||||
translation_key="shelter_alarm",
|
||||
),
|
||||
"Disk Full": BinarySensorEntityDescription(
|
||||
key="disk_full",
|
||||
translation_key="disk_full",
|
||||
|
||||
@@ -84,9 +84,6 @@
|
||||
"scene_change_detection": {
|
||||
"name": "Scene change detection"
|
||||
},
|
||||
"shelter_alarm": {
|
||||
"name": "Shelter alarm"
|
||||
},
|
||||
"unattended_baggage": {
|
||||
"name": "Unattended baggage"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.96", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.97", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"homeegram": {
|
||||
"default": "mdi:robot"
|
||||
},
|
||||
"manual_operation": {
|
||||
"default": "mdi:hand-back-left"
|
||||
},
|
||||
|
||||
@@ -499,6 +499,9 @@
|
||||
"disarm_not_supported": {
|
||||
"message": "Disarm is not supported by homee."
|
||||
},
|
||||
"homeegram_turn_off_not_supported": {
|
||||
"message": "Turning off homeegrams is not supported."
|
||||
},
|
||||
"invalid_preset_mode": {
|
||||
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
from pyHomee.model_homeegram import HomeeGram
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
@@ -14,9 +15,11 @@ from homeassistant.components.switch import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from . import DOMAIN, HomeeConfigEntry
|
||||
from .const import CLIMATE_PROFILES, LIGHT_PROFILES
|
||||
from .entity import HomeeEntity
|
||||
from .helpers import setup_homee_platform
|
||||
@@ -95,6 +98,10 @@ async def async_setup_entry(
|
||||
"""Set up the switch platform for the Homee component."""
|
||||
|
||||
await setup_homee_platform(add_switch_entities, async_add_entities, config_entry)
|
||||
async_add_entities(
|
||||
HomeegramSwitch(homeegram, config_entry)
|
||||
for homeegram in config_entry.runtime_data.homeegrams
|
||||
)
|
||||
|
||||
|
||||
class HomeeSwitch(HomeeEntity, SwitchEntity):
|
||||
@@ -137,3 +144,75 @@ class HomeeSwitch(HomeeEntity, SwitchEntity):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.async_set_homee_value(0)
|
||||
|
||||
|
||||
class HomeegramSwitch(SwitchEntity):
|
||||
"""Representation of a Homeegram as switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, homeegram: HomeeGram, entry: HomeeConfigEntry) -> None:
|
||||
"""Initialize a homee Homeegram switch entity."""
|
||||
self._homeegram = homeegram
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.unique_id}-hg-{homeegram.id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")},
|
||||
name="Homeegrams",
|
||||
model="Homeegram Switches",
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
self._attr_translation_key = "homeegram"
|
||||
self._host_connected = entry.runtime_data.connected
|
||||
self._attr_name = homeegram.name
|
||||
|
||||
self._attr_entity_registry_enabled_default = self._is_enabled_by_default(
|
||||
homeegram
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add the Homeegram entity to home assistant."""
|
||||
self.async_on_remove(
|
||||
self._homeegram.add_on_changed_listener(self._on_homeegram_updated)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self._entry.runtime_data.add_connection_listener(
|
||||
self._on_connection_changed
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if homeegram is executing."""
|
||||
return bool(self._homeegram.play)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the homeegram based on host availability."""
|
||||
return bool(self._homeegram.active) and self._host_connected
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Trigger Homeegram on switching on."""
|
||||
await self._entry.runtime_data.play_homeegram(self._homeegram.id)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turning off homeegrams is not supported."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="homeegram_turn_off_not_supported",
|
||||
)
|
||||
|
||||
def _on_homeegram_updated(self, homeegram: HomeeGram) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _on_connection_changed(self, connected: bool) -> None:
|
||||
self._host_connected = connected
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _is_enabled_by_default(self, homeegram: HomeeGram) -> bool:
|
||||
"""Return if the homeegram should be enabled by default."""
|
||||
# Only enable homeegram switches by default if there is more than 1 homeegram action.
|
||||
return (
|
||||
sum(len(action_list) for action_list in homeegram.actions.data.values()) > 1
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Support for HomematicIP Cloud binary sensor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import (
|
||||
@@ -37,6 +39,7 @@ from homematicip.group import SecurityGroup, SecurityZoneGroup
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -77,6 +80,161 @@ SAM_DEVICE_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
|
||||
def _always_exists(_device: Device) -> bool:
|
||||
"""Default exists_fn: every matched device gets the entity."""
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HmipBinarySensorDescription[_DeviceT: Device](BinarySensorEntityDescription):
|
||||
"""Describe a simple HomematicIP binary sensor."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], bool]
|
||||
exists_fn: Callable[[_DeviceT], bool] = _always_exists
|
||||
# Required: contributes to unique_id via {device.id}_{channel}_{key}. An
|
||||
# implicit default would silently lean on get_channel_index()'s fallback
|
||||
# and create a migration footgun.
|
||||
channel: int
|
||||
|
||||
|
||||
MOTION_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[
|
||||
MotionDetectorIndoor | MotionDetectorOutdoor | MotionDetectorPushButton
|
||||
],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
value_fn=lambda device: device.motionDetected,
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
PRESENCE_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[PresenceDetectorIndoor],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="presence",
|
||||
device_class=BinarySensorDeviceClass.PRESENCE,
|
||||
value_fn=lambda device: device.presenceDetected,
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
SMOKE_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[SmokeDetector],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="smoke",
|
||||
device_class=BinarySensorDeviceClass.SMOKE,
|
||||
value_fn=lambda device: (
|
||||
device.smokeDetectorAlarmType == SmokeDetectorAlarmType.PRIMARY_ALARM
|
||||
),
|
||||
channel=1,
|
||||
),
|
||||
HmipBinarySensorDescription(
|
||||
key="chamber_degraded",
|
||||
translation_key="chamber_degraded",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value_fn=lambda device: device.chamberDegraded,
|
||||
exists_fn=lambda device: smoke_detector_channel_data_exists(
|
||||
device, "chamberDegraded"
|
||||
),
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
WATER_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[WaterSensor],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="water",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
value_fn=lambda device: device.moistureDetected or device.waterlevelDetected,
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
RAIN_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[RainSensor | WeatherSensorPlus | WeatherSensorPro],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="rain",
|
||||
translation_key="raining",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
value_fn=lambda device: device.raining,
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
MAINS_FAILURE_SENSOR_DESCRIPTIONS: tuple[
|
||||
HmipBinarySensorDescription[PluggableMainsFailureSurveillance],
|
||||
...,
|
||||
] = (
|
||||
HmipBinarySensorDescription(
|
||||
key="mains_failure",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
value_fn=lambda device: not device.powerMainsFailure,
|
||||
channel=1,
|
||||
),
|
||||
)
|
||||
|
||||
BATTERY_SENSOR_DESCRIPTION = HmipBinarySensorDescription[Device](
|
||||
key="battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
value_fn=lambda device: bool(device.lowBat),
|
||||
channel=0,
|
||||
)
|
||||
|
||||
SIMPLE_BINARY_SENSOR_DESCRIPTIONS: dict[
|
||||
tuple[type, ...], tuple[HmipBinarySensorDescription[Any], ...]
|
||||
] = {
|
||||
(
|
||||
MotionDetectorIndoor,
|
||||
MotionDetectorOutdoor,
|
||||
MotionDetectorPushButton,
|
||||
): MOTION_SENSOR_DESCRIPTIONS,
|
||||
(PresenceDetectorIndoor,): PRESENCE_SENSOR_DESCRIPTIONS,
|
||||
(SmokeDetector,): SMOKE_SENSOR_DESCRIPTIONS,
|
||||
(WaterSensor,): WATER_SENSOR_DESCRIPTIONS,
|
||||
(RainSensor, WeatherSensorPlus, WeatherSensorPro): RAIN_SENSOR_DESCRIPTIONS,
|
||||
(PluggableMainsFailureSurveillance,): MAINS_FAILURE_SENSOR_DESCRIPTIONS,
|
||||
}
|
||||
|
||||
|
||||
def _create_simple_binary_sensors(
|
||||
hap: HomematicipHAP,
|
||||
device: Device,
|
||||
) -> list[HomematicipSimpleBinarySensor[Any]]:
|
||||
"""Create all simple described binary sensors for a device."""
|
||||
entities: list[HomematicipSimpleBinarySensor[Any]] = []
|
||||
|
||||
for device_types, descriptions in SIMPLE_BINARY_SENSOR_DESCRIPTIONS.items():
|
||||
if not isinstance(device, device_types):
|
||||
continue
|
||||
entities.extend(
|
||||
HomematicipSimpleBinarySensor(hap, device, description)
|
||||
for description in descriptions
|
||||
if description.exists_fn(device)
|
||||
)
|
||||
# Each device class matches at most one group key (enforced by
|
||||
# test_simple_binary_sensor_descriptions_no_overlap), so further
|
||||
# iteration cannot add entities.
|
||||
break
|
||||
|
||||
if device.lowBat is not None:
|
||||
entities.append(
|
||||
HomematicipSimpleBinarySensor(hap, device, BATTERY_SENSOR_DESCRIPTION)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
def _is_full_flush_lock_controller(device: object) -> bool:
|
||||
"""Return whether the device is an HmIP-FLC."""
|
||||
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
|
||||
@@ -136,37 +294,15 @@ async def async_setup_entry(
|
||||
entities.append(HomematicipShutterContact(hap, device))
|
||||
if isinstance(device, RotaryHandleSensor):
|
||||
entities.append(HomematicipShutterContact(hap, device, True))
|
||||
if isinstance(
|
||||
device,
|
||||
(
|
||||
MotionDetectorIndoor,
|
||||
MotionDetectorOutdoor,
|
||||
MotionDetectorPushButton,
|
||||
),
|
||||
):
|
||||
entities.append(HomematicipMotionDetector(hap, device))
|
||||
if isinstance(device, PluggableMainsFailureSurveillance):
|
||||
entities.append(
|
||||
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
|
||||
)
|
||||
if isinstance(device, Device):
|
||||
entities.extend(_create_simple_binary_sensors(hap, device))
|
||||
|
||||
if _is_full_flush_lock_controller(device):
|
||||
entities.append(HomematicipFullFlushLockControllerLocked(hap, device))
|
||||
entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device))
|
||||
if isinstance(device, PresenceDetectorIndoor):
|
||||
entities.append(HomematicipPresenceDetector(hap, device))
|
||||
if isinstance(device, SmokeDetector):
|
||||
entities.append(HomematicipSmokeDetector(hap, device))
|
||||
if smoke_detector_channel_data_exists(device, "chamberDegraded"):
|
||||
entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device))
|
||||
if isinstance(device, WaterSensor):
|
||||
entities.append(HomematicipWaterDetector(hap, device))
|
||||
if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
|
||||
entities.append(HomematicipRainSensor(hap, device))
|
||||
if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
|
||||
entities.append(HomematicipStormSensor(hap, device))
|
||||
entities.append(HomematicipSunshineSensor(hap, device))
|
||||
if isinstance(device, Device) and device.lowBat is not None:
|
||||
entities.append(HomematicipBatterySensor(hap, device))
|
||||
|
||||
for group in hap.home.groups:
|
||||
if isinstance(group, SecurityGroup):
|
||||
@@ -177,6 +313,35 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomematicipSimpleBinarySensor[_DeviceT: Device](
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""A simple HomematicIP binary sensor backed by an entity description."""
|
||||
|
||||
entity_description: HmipBinarySensorDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hap: HomematicipHAP,
|
||||
device: _DeviceT,
|
||||
description: HmipBinarySensorDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize the described binary sensor."""
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
channel=description.channel,
|
||||
feature_id=description.key,
|
||||
use_description_name=True,
|
||||
)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the binary sensor is on."""
|
||||
return self.entity_description.value_fn(self._device)
|
||||
|
||||
|
||||
class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP cloud connection sensor."""
|
||||
|
||||
@@ -326,21 +491,6 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn
|
||||
return state_attr
|
||||
|
||||
|
||||
class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP motion detector."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the motion detector."""
|
||||
super().__init__(hap, device, feature_id="motion")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if motion is detected."""
|
||||
return self._device.motionDetected
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerLocked(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
@@ -413,75 +563,6 @@ class HomematicipFullFlushLockControllerGlassBreak(
|
||||
return bool(getattr(channel, "glassBroken", False))
|
||||
|
||||
|
||||
class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP presence detector."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PRESENCE
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the presence detector."""
|
||||
super().__init__(hap, device, feature_id="presence")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if presence is detected."""
|
||||
return self._device.presenceDetected
|
||||
|
||||
|
||||
class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP smoke detector."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.SMOKE
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the smoke detector."""
|
||||
super().__init__(hap, device, feature_id="smoke")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if smoke is detected."""
|
||||
if self._device.smokeDetectorAlarmType:
|
||||
return (
|
||||
self._device.smokeDetectorAlarmType
|
||||
== SmokeDetectorAlarmType.PRIMARY_ALARM
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class HomematicipSmokeDetectorChamberDegraded(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""Representation of the HomematicIP smoke detector chamber health."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize smoke detector chamber health sensor."""
|
||||
super().__init__(
|
||||
hap, device, post="Chamber Degraded", feature_id="chamber_degraded"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if smoke chamber is degraded."""
|
||||
return self._device.chamberDegraded
|
||||
|
||||
|
||||
class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP water detector."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOISTURE
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the water detector."""
|
||||
super().__init__(hap, device, feature_id="water")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true, if moisture or waterlevel is detected."""
|
||||
return self._device.moistureDetected or self._device.waterlevelDetected
|
||||
|
||||
|
||||
class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP storm sensor."""
|
||||
|
||||
@@ -500,21 +581,6 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
return self._device.storm
|
||||
|
||||
|
||||
class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP rain sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOISTURE
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize rain sensor."""
|
||||
super().__init__(hap, device, "Raining", feature_id="rain")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true, if it is raining."""
|
||||
return self._device.raining
|
||||
|
||||
|
||||
class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP sunshine sensor."""
|
||||
|
||||
@@ -541,38 +607,6 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
return state_attr
|
||||
|
||||
|
||||
class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP low battery sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize battery sensor."""
|
||||
super().__init__(hap, device, post="Battery", channel=0, feature_id="battery")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if battery is low."""
|
||||
return self._device.lowBat
|
||||
|
||||
|
||||
class HomematicipPluggableMainsFailureSurveillanceSensor(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""Representation of the HomematicIP pluggable mains failure surveillance sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.POWER
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize pluggable mains failure surveillance sensor."""
|
||||
super().__init__(hap, device, feature_id="mains_failure")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if power mains fails."""
|
||||
return not self._device.powerMainsFailure
|
||||
|
||||
|
||||
class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP security zone sensor group."""
|
||||
|
||||
|
||||
@@ -87,8 +87,15 @@ class HomematicipGenericEntity(Entity):
|
||||
channel_real_index: int | None = None,
|
||||
*,
|
||||
feature_id: str,
|
||||
use_description_name: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the generic entity."""
|
||||
"""Initialize the generic entity.
|
||||
|
||||
When ``use_description_name`` is True, leave ``_attr_name`` unset so
|
||||
HA's standard name resolution (``EntityDescription.name``,
|
||||
``device_class``, ``translation_key`` + placeholders) drives the
|
||||
entity name. Default False keeps the legacy channel/post composition.
|
||||
"""
|
||||
self._hap = hap
|
||||
self._home: AsyncHome = hap.home
|
||||
self._device = device
|
||||
@@ -118,7 +125,7 @@ class HomematicipGenericEntity(Entity):
|
||||
# Legacy mode (groups, special entities): compose the full name
|
||||
# including device/group label and home prefix.
|
||||
self._attr_name = self._compute_legacy_name()
|
||||
else:
|
||||
elif not use_description_name:
|
||||
self._setup_entity_name()
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homematicip.base.channel_event import ChannelEvent
|
||||
from homematicip.base.enums import FunctionalChannelType
|
||||
from homematicip.base.functionalChannels import FunctionalChannel
|
||||
from homematicip.device import Device
|
||||
|
||||
@@ -24,20 +24,41 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
class HmipEventEntityDescription(EventEntityDescription):
|
||||
"""Description of a HomematicIP Cloud event."""
|
||||
|
||||
channel_event_types: list[str] | None = None
|
||||
channel_selector_fn: Callable[[FunctionalChannel], bool] | None = None
|
||||
event_type_map: dict[str, str]
|
||||
channel_selector_fn: Callable[[FunctionalChannel], bool]
|
||||
is_multi_channel: bool = False
|
||||
|
||||
|
||||
EVENT_DESCRIPTIONS = {
|
||||
"doorbell": HmipEventEntityDescription(
|
||||
EVENT_DESCRIPTIONS: tuple[HmipEventEntityDescription, ...] = (
|
||||
HmipEventEntityDescription(
|
||||
key="doorbell",
|
||||
translation_key="doorbell",
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
event_types=["ring"],
|
||||
channel_event_types=["DOOR_BELL_SENSOR_EVENT"],
|
||||
event_type_map={"DOOR_BELL_SENSOR_EVENT": "ring"},
|
||||
channel_selector_fn=lambda channel: channel.channelRole == "DOOR_BELL_INPUT",
|
||||
),
|
||||
}
|
||||
# Button event types follow the standard names proposed in
|
||||
# home-assistant/architecture#1377: short_release, long_press,
|
||||
# long_release. HmIP doesn't expose a separate press-down ("initial_press")
|
||||
# event for short presses; KEY_PRESS_LONG_START is mapped to long_press
|
||||
# (no separate initial_press fires for the hold sequence either).
|
||||
HmipEventEntityDescription(
|
||||
key="button",
|
||||
translation_key="button",
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
event_types=["short_release", "long_press", "long_release"],
|
||||
event_type_map={
|
||||
"KEY_PRESS_SHORT": "short_release",
|
||||
"KEY_PRESS_LONG_START": "long_press",
|
||||
"KEY_PRESS_LONG_STOP": "long_release",
|
||||
},
|
||||
channel_selector_fn=lambda channel: (
|
||||
channel.functionalChannelType == FunctionalChannelType.SINGLE_KEY_CHANNEL
|
||||
),
|
||||
is_multi_channel=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -45,49 +66,43 @@ async def async_setup_entry(
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP cover from a config entry."""
|
||||
"""Set up the HomematicIP events from a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
entities: list[HomematicipGenericEntity] = []
|
||||
|
||||
entities.extend(
|
||||
HomematicipDoorBellEvent(
|
||||
hap,
|
||||
device,
|
||||
channel.index,
|
||||
description,
|
||||
)
|
||||
for description in EVENT_DESCRIPTIONS.values()
|
||||
async_add_entities(
|
||||
HomematicipChannelEvent(hap, device, channel, description)
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
for device in hap.home.devices
|
||||
for channel in device.functionalChannels
|
||||
if description.channel_selector_fn and description.channel_selector_fn(channel)
|
||||
if description.channel_selector_fn(channel)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
class HomematicipChannelEvent(HomematicipGenericEntity, EventEntity):
|
||||
"""Event entity backed by a HomematicIP functional channel."""
|
||||
|
||||
class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
|
||||
"""Event class for HomematicIP doorbell events."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.DOORBELL
|
||||
entity_description: HmipEventEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hap: HomematicipHAP,
|
||||
device: Device,
|
||||
channel: int,
|
||||
channel: FunctionalChannel,
|
||||
description: HmipEventEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the event."""
|
||||
"""Initialize the channel-backed event entity."""
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
channel=channel,
|
||||
is_multi_channel=False,
|
||||
feature_id="doorbell",
|
||||
channel=channel.index,
|
||||
channel_real_index=channel.index if description.is_multi_channel else None,
|
||||
is_multi_channel=description.is_multi_channel,
|
||||
feature_id=description.key,
|
||||
use_description_name=description.is_multi_channel,
|
||||
)
|
||||
|
||||
self.entity_description = description
|
||||
if description.is_multi_channel:
|
||||
self._attr_translation_placeholders = {"channel": str(channel.index)}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -99,24 +114,15 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
|
||||
@callback
|
||||
def _async_handle_event(self, *args, **kwargs) -> None:
|
||||
"""Handle the event fired by the functional channel."""
|
||||
raised_channel_event = self._get_channel_event_from_args(*args)
|
||||
|
||||
if not self._should_raise(raised_channel_event):
|
||||
raw_channel_event_type = self._get_channel_event_from_args(*args)
|
||||
public_event = self.entity_description.event_type_map.get(
|
||||
raw_channel_event_type
|
||||
)
|
||||
if public_event is None:
|
||||
return
|
||||
|
||||
event_types = self.entity_description.event_types
|
||||
if TYPE_CHECKING:
|
||||
assert event_types is not None
|
||||
|
||||
self._trigger_event(event_type=event_types[0])
|
||||
self._trigger_event(event_type=public_event)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _should_raise(self, event_type: str) -> bool:
|
||||
"""Check if the event should be raised."""
|
||||
if self.entity_description.channel_event_types is None:
|
||||
return False
|
||||
return event_type in self.entity_description.channel_event_types
|
||||
|
||||
def _get_channel_event_from_args(self, *args) -> str:
|
||||
"""Get the channel event."""
|
||||
if isinstance(args[0], ChannelEvent):
|
||||
|
||||
@@ -38,8 +38,28 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"chamber_degraded": {
|
||||
"name": "Chamber degraded"
|
||||
},
|
||||
"cloud_connection": {
|
||||
"name": "Cloud connection"
|
||||
},
|
||||
"raining": {
|
||||
"name": "Raining"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"button": {
|
||||
"name": "Button {channel}",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"long_press": "Long press",
|
||||
"long_release": "Long release",
|
||||
"short_release": "Short release"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "homewizard"
|
||||
ISSUE_BATTERY_MODE_CLOUD_DISABLED = "battery_mode_cloud_disabled"
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
@@ -22,3 +23,8 @@ CONF_PRODUCT_TYPE = "product_type"
|
||||
CONF_SERIAL = "serial"
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
def battery_mode_cloud_issue_id(entry_id: str) -> str:
|
||||
"""Build issue id for battery mode/cloud incompatibility."""
|
||||
return f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_{entry_id}"
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
from homewizard_energy import HomeWizardEnergy
|
||||
from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
|
||||
from homewizard_energy.models import CombinedModels as DeviceResponseEntry
|
||||
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
ISSUE_BATTERY_MODE_CLOUD_DISABLED,
|
||||
LOGGER,
|
||||
UPDATE_INTERVAL,
|
||||
battery_mode_cloud_issue_id,
|
||||
)
|
||||
|
||||
type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator]
|
||||
|
||||
@@ -38,6 +45,34 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
|
||||
)
|
||||
self.api = api
|
||||
|
||||
def _update_battery_mode_cloud_repair_issue(
|
||||
self, data: DeviceResponseEntry
|
||||
) -> None:
|
||||
"""Update repair issue for incompatible battery mode and cloud state."""
|
||||
battery_mode_cloud_issue_active = (
|
||||
data.batteries is not None
|
||||
and data.system is not None
|
||||
and data.batteries.mode == Batteries.Mode.PREDICTIVE.value
|
||||
and data.system.cloud_enabled is False
|
||||
)
|
||||
issue_id = battery_mode_cloud_issue_id(self.config_entry.entry_id)
|
||||
issue_exists = (
|
||||
ir.async_get(self.hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
)
|
||||
if battery_mode_cloud_issue_active and not issue_exists:
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
translation_key=ISSUE_BATTERY_MODE_CLOUD_DISABLED,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
data={"entry_id": self.config_entry.entry_id},
|
||||
)
|
||||
elif not battery_mode_cloud_issue_active and issue_exists:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
async def _async_update_data(self) -> DeviceResponseEntry:
|
||||
"""Fetch all device and sensor data from api."""
|
||||
try:
|
||||
@@ -70,6 +105,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
|
||||
self.api_disabled = False
|
||||
self._update_battery_mode_cloud_repair_issue(data)
|
||||
|
||||
self.data = data
|
||||
return data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the HomeWizard integration."""
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_SERIAL_NUMBER
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -28,3 +28,4 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
|
||||
(CONNECTION_NETWORK_MAC, serial_number)
|
||||
}
|
||||
self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)}
|
||||
self._attr_device_info[ATTR_SERIAL_NUMBER] = serial_number
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Repairs for HomeWizard integration."""
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homewizard_energy.errors import RequestError
|
||||
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .config_flow import async_request_token
|
||||
from .const import ISSUE_BATTERY_MODE_CLOUD_DISABLED
|
||||
|
||||
|
||||
class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
@@ -59,18 +66,54 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
|
||||
class BatteryModeCloudDisabledRepairFlow(RepairsFlow):
|
||||
"""Handler for a battery mode/cloud incompatibility fix flow."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Create flow."""
|
||||
self.entry = entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input is not None:
|
||||
coordinator = self.entry.runtime_data
|
||||
try:
|
||||
await coordinator.api.system(cloud_enabled=True)
|
||||
except RequestError:
|
||||
errors = {"base": "network_error"}
|
||||
else:
|
||||
await coordinator.async_refresh()
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_show_form(step_id="confirm", errors=errors)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
assert data is not None
|
||||
assert isinstance(data["entry_id"], str)
|
||||
if data is None or not isinstance(entry_id := data.get("entry_id"), str):
|
||||
return ConfirmRepairFlow()
|
||||
|
||||
if issue_id.startswith("migrate_to_v2_api_") and (
|
||||
entry := hass.config_entries.async_get_entry(data["entry_id"])
|
||||
entry := hass.config_entries.async_get_entry(entry_id)
|
||||
):
|
||||
return MigrateToV2ApiRepairFlow(entry)
|
||||
|
||||
if issue_id.startswith(f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_") and (
|
||||
entry := hass.config_entries.async_get_entry(entry_id)
|
||||
):
|
||||
return BatteryModeCloudDisabledRepairFlow(entry)
|
||||
|
||||
raise ValueError(f"unknown repair {issue_id}") # pragma: no cover
|
||||
|
||||
@@ -632,6 +632,32 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
has_fn=lambda data: data.measurement.cycles is not None,
|
||||
value_fn=lambda data: data.measurement.cycles,
|
||||
),
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="battery_group_power_w",
|
||||
translation_key="battery_group_power_w",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=lambda data: data.batteries is not None,
|
||||
value_fn=lambda data: (
|
||||
data.batteries.power_w if data.batteries is not None else None
|
||||
),
|
||||
),
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="battery_group_target_power_w",
|
||||
translation_key="battery_group_target_power_w",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=lambda data: data.batteries is not None,
|
||||
value_fn=lambda data: (
|
||||
data.batteries.target_power_w if data.batteries is not None else None
|
||||
),
|
||||
),
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
|
||||
@@ -106,6 +106,12 @@
|
||||
"any_power_fail_count": {
|
||||
"name": "Power failures detected"
|
||||
},
|
||||
"battery_group_power_w": {
|
||||
"name": "Battery group power"
|
||||
},
|
||||
"battery_group_target_power_w": {
|
||||
"name": "Battery group target power"
|
||||
},
|
||||
"cycles": {
|
||||
"name": "Battery cycles"
|
||||
},
|
||||
@@ -182,6 +188,20 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"battery_mode_cloud_disabled": {
|
||||
"fix_flow": {
|
||||
"error": {
|
||||
"network_error": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Smart charging strategy is enabled for your battery group, but cloud connection is disabled. These settings are not compatible, as smart charging requires cloud connectivity.\n\nSelect **Submit** to enable cloud connection.",
|
||||
"title": "[%key:component::homewizard::issues::battery_mode_cloud_disabled::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Enable cloud connection for smart charging strategy"
|
||||
},
|
||||
"migrate_to_v2_api": {
|
||||
"fix_flow": {
|
||||
"error": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["python_qube_heatpump"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-qube-heatpump==1.10.0"]
|
||||
"requirements": ["python-qube-heatpump==1.11.0"]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AqualinkDataUpdateCoordinator
|
||||
from .entity import AqualinkEntity
|
||||
from .utils import error_detail
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception:
|
||||
await aqualink.close()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while attempting login: {aio_exception}"
|
||||
f"Error while attempting login: {error_detail(aio_exception)}"
|
||||
) from aio_exception
|
||||
|
||||
try:
|
||||
@@ -96,10 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid credentials for iAquaLink"
|
||||
) from auth_exception
|
||||
except AqualinkServiceException as svc_exception:
|
||||
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as svc_exception:
|
||||
await aqualink.close()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while attempting to retrieve systems list: {svc_exception}"
|
||||
"Error while attempting to retrieve systems list: "
|
||||
f"{error_detail(svc_exception)}"
|
||||
) from svc_exception
|
||||
|
||||
systems_list = list(systems.values())
|
||||
@@ -132,10 +134,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid credentials for iAquaLink"
|
||||
) from auth_exception
|
||||
except AqualinkServiceException as svc_exception:
|
||||
except (
|
||||
AqualinkServiceException,
|
||||
TimeoutError,
|
||||
httpx.HTTPError,
|
||||
) as svc_exception:
|
||||
await aqualink.close()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while attempting to retrieve devices list: {svc_exception}"
|
||||
"Error while attempting to retrieve devices list: "
|
||||
f"{error_detail(svc_exception)}"
|
||||
) from svc_exception
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -74,9 +74,13 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity):
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Turn the underlying heater switch on or off."""
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await await_or_reraise(self.dev.turn_on())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_on()
|
||||
)
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
await await_or_reraise(self.dev.turn_off())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_off()
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Unknown operation mode: %s", hvac_mode)
|
||||
|
||||
@@ -98,7 +102,11 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity):
|
||||
@refresh_system
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])))
|
||||
await await_or_reraise(
|
||||
self.hass,
|
||||
self.coordinator.config_entry,
|
||||
self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])),
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow to configure zone component."""
|
||||
"""Config flow for iAquaLink."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -31,7 +31,7 @@ CREDENTIALS_DATA_SCHEMA = vol.Schema(
|
||||
|
||||
|
||||
class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Aqualink config flow."""
|
||||
"""iAquaLink config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@@ -50,7 +50,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
pass
|
||||
except AqualinkServiceUnauthorizedException:
|
||||
return {"base": "invalid_auth"}
|
||||
except AqualinkServiceException, httpx.HTTPError:
|
||||
except AqualinkServiceException, TimeoutError, httpx.HTTPError:
|
||||
return {"base": "cannot_connect"}
|
||||
|
||||
return {}
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
|
||||
from .utils import error_detail
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,9 +52,10 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
self.system.serial,
|
||||
)
|
||||
return
|
||||
except (AqualinkServiceException, httpx.HTTPError) as err:
|
||||
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to update iAquaLink system {self.system.serial}: {err}"
|
||||
"Unable to update iAquaLink system "
|
||||
f"{self.system.serial}: {error_detail(err)}"
|
||||
) from err
|
||||
if self.system.online is not True:
|
||||
raise UpdateFailed(f"iAquaLink system {self.system.serial} is offline")
|
||||
|
||||
@@ -67,18 +67,28 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity):
|
||||
"""
|
||||
# For now I'm assuming lights support either effects or brightness.
|
||||
if effect_name := kwargs.get(ATTR_EFFECT):
|
||||
await await_or_reraise(self.dev.set_effect_by_name(effect_name))
|
||||
await await_or_reraise(
|
||||
self.hass,
|
||||
self.coordinator.config_entry,
|
||||
self.dev.set_effect_by_name(effect_name),
|
||||
)
|
||||
elif brightness := kwargs.get(ATTR_BRIGHTNESS):
|
||||
# Aqualink supports percentages in 25% increments.
|
||||
pct = round(brightness * 4.0 / 255) * 25
|
||||
await await_or_reraise(self.dev.set_brightness(pct))
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.set_brightness(pct)
|
||||
)
|
||||
else:
|
||||
await await_or_reraise(self.dev.turn_on())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_on()
|
||||
)
|
||||
|
||||
@refresh_system
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await await_or_reraise(self.dev.turn_off())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_off()
|
||||
)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
@@ -86,7 +96,7 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity):
|
||||
|
||||
The scale needs converting between 0-100 and 0-255.
|
||||
"""
|
||||
return self.dev.brightness * 255 / 100
|
||||
return round(self.dev.brightness * 255 / 100)
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["iaqualink"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["iaqualink==0.7.0", "h2==4.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -35,7 +35,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -56,9 +56,13 @@ class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity):
|
||||
@refresh_system
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await await_or_reraise(self.dev.turn_on())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_on()
|
||||
)
|
||||
|
||||
@refresh_system
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await await_or_reraise(self.dev.turn_off())
|
||||
await await_or_reraise(
|
||||
self.hass, self.coordinator.config_entry, self.dev.turn_off()
|
||||
)
|
||||
|
||||
@@ -3,14 +3,42 @@
|
||||
from collections.abc import Awaitable
|
||||
|
||||
import httpx
|
||||
from iaqualink.exception import AqualinkServiceException
|
||||
from iaqualink.exception import (
|
||||
AqualinkServiceException,
|
||||
AqualinkServiceUnauthorizedException,
|
||||
)
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
|
||||
|
||||
async def await_or_reraise(awaitable: Awaitable) -> None:
|
||||
def error_detail(err: Exception) -> str:
|
||||
"""Return a non-empty error detail for iaqualink exceptions."""
|
||||
if detail := str(err):
|
||||
return detail
|
||||
return type(err).__name__
|
||||
|
||||
|
||||
async def await_or_reraise(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry | None,
|
||||
awaitable: Awaitable,
|
||||
) -> None:
|
||||
"""Execute API call while catching service exceptions."""
|
||||
try:
|
||||
await awaitable
|
||||
except AqualinkServiceUnauthorizedException as auth_exception:
|
||||
if config_entry is not None:
|
||||
config_entry.async_start_reauth(hass)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid credentials for iAquaLink"
|
||||
) from auth_exception
|
||||
except TimeoutError as timeout_exception:
|
||||
raise HomeAssistantError(
|
||||
f"Aqualink error: {error_detail(timeout_exception)}"
|
||||
) from timeout_exception
|
||||
except (AqualinkServiceException, httpx.HTTPError) as svc_exception:
|
||||
raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception
|
||||
raise HomeAssistantError(
|
||||
f"Aqualink error: {error_detail(svc_exception)}"
|
||||
) from svc_exception
|
||||
|
||||
@@ -116,6 +116,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltBattery.PACK_3_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_TEMPERATURE,
|
||||
IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["indevolt-api==1.8.1"],
|
||||
"requirements": ["indevolt-api==1.8.2"],
|
||||
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
|
||||
}
|
||||
|
||||
@@ -612,6 +612,16 @@ SENSORS: Final = (
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Battery Pack MOS Temperature
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
translation_key="main_mos_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
|
||||
@@ -295,6 +295,9 @@
|
||||
"main_current": {
|
||||
"name": "Main current"
|
||||
},
|
||||
"main_mos_temperature": {
|
||||
"name": "Main MOS temperature"
|
||||
},
|
||||
"main_serial_number": {
|
||||
"name": "Main serial number"
|
||||
},
|
||||
|
||||
@@ -63,5 +63,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==1.2.3"]
|
||||
"requirements": ["inkbird-ble==1.4.4"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pyjoin"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["python-join-api==0.0.9"]
|
||||
"requirements": ["python-join-api==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ from kiosker import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_API_TOKEN, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
|
||||
from .const import DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
DOMAIN = "kiosker"
|
||||
|
||||
# Configuration keys
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_API_TOKEN = "api_token"
|
||||
|
||||
# Default values
|
||||
PORT = 8081
|
||||
POLL_INTERVAL = 15
|
||||
|
||||
@@ -18,12 +18,12 @@ from kiosker import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_API_TOKEN, DOMAIN, POLL_INTERVAL, PORT
|
||||
from .const import DOMAIN, POLL_INTERVAL, PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import serial
|
||||
import serialx
|
||||
import ultraheat_api
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -103,7 +103,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# validate and retrieve the model and device number for a unique id
|
||||
data = await self.hass.async_add_executor_job(heat_meter.read)
|
||||
|
||||
except (TimeoutError, serial.SerialException) as err:
|
||||
except (OSError, TimeoutError, serialx.SerialException) as err:
|
||||
_LOGGER.warning("Failed read data from: %s. %s", port, err)
|
||||
raise CannotConnect(f"Error communicating with device: {err}") from err
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import serial
|
||||
import serialx
|
||||
from ultraheat_api.response import HeatMeterResponse
|
||||
from ultraheat_api.service import HeatMeterService
|
||||
|
||||
@@ -44,5 +44,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]):
|
||||
try:
|
||||
async with asyncio.timeout(ULTRAHEAT_TIMEOUT):
|
||||
return await self.hass.async_add_executor_job(self.api.read)
|
||||
except (FileNotFoundError, serial.SerialException) as err:
|
||||
except (OSError, TimeoutError, serialx.SerialException) as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ultraheat-api==0.5.7"]
|
||||
"requirements": ["ultraheat-api==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Local file from a config entry."""
|
||||
file_path: str = entry.options[CONF_FILE_PATH]
|
||||
if not await hass.async_add_executor_job(check_file_path_access, file_path):
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_readable_path",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user