mirror of
https://github.com/home-assistant/core.git
synced 2025-09-26 05:19:24 +00:00
Compare commits
115 Commits
imap-fetch
...
2025.10.0b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
156a0f1a3d | ||
![]() |
cc2a5b43dd | ||
![]() |
731064f7e9 | ||
![]() |
2f75661c20 | ||
![]() |
be6f056f30 | ||
![]() |
79599e1284 | ||
![]() |
a255585ab6 | ||
![]() |
e9bde225fe | ||
![]() |
d9521ac2a0 | ||
![]() |
d8b24ccccd | ||
![]() |
b4417a76d5 | ||
![]() |
274f6eb54a | ||
![]() |
21a5aaf35c | ||
![]() |
05820a49d0 | ||
![]() |
17b12d29af | ||
![]() |
9cc78680d6 | ||
![]() |
14d42e43bf | ||
![]() |
ed5f5d4b33 | ||
![]() |
c3ba086fad | ||
![]() |
7b5314605c | ||
![]() |
3a806d6603 | ||
![]() |
6dd33f900d | ||
![]() |
2844bd474a | ||
![]() |
d865fcf999 | ||
![]() |
79a2fc5a01 | ||
![]() |
19d87abb8a | ||
![]() |
c4de46a85b | ||
![]() |
e79a434d9b | ||
![]() |
9a801424c7 | ||
![]() |
5cb186980a | ||
![]() |
1629ade97f | ||
![]() |
ccf0011ac2 | ||
![]() |
70077511a3 | ||
![]() |
dfbaf66021 | ||
![]() |
62cea48a58 | ||
![]() |
c493c7dd67 | ||
![]() |
fdaceaddfd | ||
![]() |
a2f4073d54 | ||
![]() |
2d01a99ec2 | ||
![]() |
311d4c4262 | ||
![]() |
e14f5ba44d | ||
![]() |
9babc85517 | ||
![]() |
332a3fad3c | ||
![]() |
8782aa4f60 | ||
![]() |
475b84cc5f | ||
![]() |
0f904d418b | ||
![]() |
4ea4eec2d8 | ||
![]() |
afefa16615 | ||
![]() |
1dccbee45c | ||
![]() |
711a56db2f | ||
![]() |
9d1c7dadff | ||
![]() |
7d1953e387 | ||
![]() |
023ecf2a64 | ||
![]() |
934db458a3 | ||
![]() |
0a6ae3b52a | ||
![]() |
bdd0b74d51 | ||
![]() |
8837f2aca7 | ||
![]() |
403cd2d8ef | ||
![]() |
ddfc528d63 | ||
![]() |
ddea2206c3 | ||
![]() |
32aacac550 | ||
![]() |
dadba274aa | ||
![]() |
14b5b9742c | ||
![]() |
a0be737925 | ||
![]() |
ff47839c61 | ||
![]() |
9ba7dda864 | ||
![]() |
911f901d9d | ||
![]() |
2008a73657 | ||
![]() |
60bf298ca6 | ||
![]() |
3bc2ea7b5f | ||
![]() |
3bac6b86df | ||
![]() |
20293e2a11 | ||
![]() |
15cc28e6c1 | ||
![]() |
874ca1323b | ||
![]() |
ca186925af | ||
![]() |
2ab051b716 | ||
![]() |
a2a726de34 | ||
![]() |
5d543d2185 | ||
![]() |
a78c909b34 | ||
![]() |
f00ab80d17 | ||
![]() |
014881d985 | ||
![]() |
29a42a8e58 | ||
![]() |
3f70084d7f | ||
![]() |
b1ae9c95c9 | ||
![]() |
8be79ecdb0 | ||
![]() |
f6b8aa893b | ||
![]() |
c867026bdd | ||
![]() |
da3a164e66 | ||
![]() |
32688e1108 | ||
![]() |
4305ea9b4c | ||
![]() |
61153ec456 | ||
![]() |
9e4a2d5fa9 | ||
![]() |
72e608918b | ||
![]() |
86db60c442 | ||
![]() |
25806615a9 | ||
![]() |
a0f67381e5 | ||
![]() |
90bfadda9b | ||
![]() |
0f8e700965 | ||
![]() |
21d4ed2837 | ||
![]() |
ce363b3835 | ||
![]() |
dd3e6b8df5 | ||
![]() |
abbf8390ac | ||
![]() |
689039959c | ||
![]() |
52c25cfc88 | ||
![]() |
00b2017767 | ||
![]() |
dd7f7be6ad | ||
![]() |
22709506c6 | ||
![]() |
f0c0492375 | ||
![]() |
58459cb80f | ||
![]() |
a19e378447 | ||
![]() |
38a5a3ed4b | ||
![]() |
e76bed4a83 | ||
![]() |
d73309ba60 | ||
![]() |
19fdea024c | ||
![]() |
a3cfd7f707 |
@@ -58,6 +58,7 @@ base_platforms: &base_platforms
|
||||
# Extra components that trigger the full suite
|
||||
components: &components
|
||||
- homeassistant/components/alexa/**
|
||||
- homeassistant/components/analytics/**
|
||||
- homeassistant/components/application_credentials/**
|
||||
- homeassistant/components/assist_pipeline/**
|
||||
- homeassistant/components/auth/**
|
||||
|
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
uses: home-assistant/wheels@2025.09.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
uses: home-assistant/wheels@2025.09.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
@@ -443,6 +443,7 @@ homeassistant.components.rituals_perfume_genie.*
|
||||
homeassistant.components.roborock.*
|
||||
homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.route_b_smart_meter.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
|
8
CODEOWNERS
generated
8
CODEOWNERS
generated
@@ -316,6 +316,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/tests/components/cups/ @fabaff
|
||||
/homeassistant/components/cync/ @Kinachi249
|
||||
/tests/components/cync/ @Kinachi249
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
@@ -410,6 +412,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/eheimdigital/ @autinerd
|
||||
/tests/components/eheimdigital/ @autinerd
|
||||
/homeassistant/components/ekeybionyx/ @richardpolzer
|
||||
/tests/components/ekeybionyx/ @richardpolzer
|
||||
/homeassistant/components/electrasmart/ @jafar-atili
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
@@ -972,8 +976,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moat/ @bdraco
|
||||
/homeassistant/components/mobile_app/ @home-assistant/core
|
||||
/tests/components/mobile_app/ @home-assistant/core
|
||||
/homeassistant/components/modbus/ @janiversen
|
||||
/tests/components/modbus/ @janiversen
|
||||
/homeassistant/components/modem_callerid/ @tkdrob
|
||||
/tests/components/modem_callerid/ @tkdrob
|
||||
/homeassistant/components/modern_forms/ @wonderslug
|
||||
@@ -1332,6 +1334,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
|
||||
/homeassistant/components/roon/ @pavoni
|
||||
/tests/components/roon/ @pavoni
|
||||
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
|
||||
/tests/components/route_b_smart_meter/ @SeraphicRav
|
||||
/homeassistant/components/rpi_power/ @shenxn @swetoast
|
||||
/tests/components/rpi_power/ @shenxn @swetoast
|
||||
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
||||
|
@@ -8,6 +8,7 @@ import logging
|
||||
from aioacaia.acaiascale import AcaiaScale
|
||||
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
|
||||
|
||||
from homeassistant.components.bluetooth import async_get_scanner
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -42,6 +43,7 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
|
||||
name=entry.title,
|
||||
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
|
||||
notify_callback=self.async_update_listeners,
|
||||
scanner=async_get_scanner(hass),
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.14"]
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
}
|
||||
|
@@ -6,17 +6,19 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
|
||||
from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout
|
||||
from aioairzone.const import (
|
||||
API_COLD_ANGLE,
|
||||
API_HEAT_ANGLE,
|
||||
API_MODE,
|
||||
API_Q_ADAPT,
|
||||
API_SLEEP,
|
||||
AZD_COLD_ANGLE,
|
||||
AZD_HEAT_ANGLE,
|
||||
AZD_MASTER,
|
||||
AZD_MODE,
|
||||
AZD_MODES,
|
||||
AZD_Q_ADAPT,
|
||||
AZD_SLEEP,
|
||||
AZD_ZONES,
|
||||
)
|
||||
@@ -65,6 +67,14 @@ SLEEP_DICT: Final[dict[str, int]] = {
|
||||
"90m": SleepTimeout.SLEEP_90,
|
||||
}
|
||||
|
||||
Q_ADAPT_DICT: Final[dict[str, int]] = {
|
||||
"standard": QAdapt.STANDARD,
|
||||
"power": QAdapt.POWER,
|
||||
"silence": QAdapt.SILENCE,
|
||||
"minimum": QAdapt.MINIMUM,
|
||||
"maximum": QAdapt.MAXIMUM,
|
||||
}
|
||||
|
||||
|
||||
def main_zone_options(
|
||||
zone_data: dict[str, Any],
|
||||
@@ -83,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
options_fn=main_zone_options,
|
||||
translation_key="modes",
|
||||
),
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_Q_ADAPT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key=AZD_Q_ADAPT,
|
||||
options=list(Q_ADAPT_DICT),
|
||||
options_dict=Q_ADAPT_DICT,
|
||||
translation_key="q_adapt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -63,6 +63,16 @@
|
||||
"stop": "Stop"
|
||||
}
|
||||
},
|
||||
"q_adapt": {
|
||||
"name": "Q-Adapt",
|
||||
"state": {
|
||||
"standard": "Standard",
|
||||
"power": "Power",
|
||||
"silence": "Silence",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
}
|
||||
},
|
||||
"sleep_times": {
|
||||
"name": "Sleep",
|
||||
"state": {
|
||||
|
@@ -94,12 +94,24 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||
)
|
||||
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(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.is_supported(
|
||||
coordinator.data[serial_num], sensor_desc.key
|
||||
)
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.0.0"]
|
||||
}
|
||||
|
@@ -57,13 +57,23 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
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(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
@@ -62,12 +62,22 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
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(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
|
@@ -48,12 +48,22 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in coordinator.data
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
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(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in new_devices
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
@@ -12,10 +12,25 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .analytics import Analytics
|
||||
from .analytics import (
|
||||
Analytics,
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
DeviceAnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsInput",
|
||||
"AnalyticsModifications",
|
||||
"DeviceAnalyticsModifications",
|
||||
"EntityAnalyticsModifications",
|
||||
"async_devices_payload",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Protocol
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
@@ -35,11 +36,14 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.setup import async_get_loaded_integrations
|
||||
@@ -75,12 +79,115 @@ from .const import (
|
||||
ATTR_USER_COUNT,
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
|
||||
|
||||
type AnalyticsModifier = Callable[
|
||||
[HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
|
||||
]
|
||||
|
||||
|
||||
@singleton(DATA_ANALYTICS_MODIFIERS)
|
||||
def _async_get_modifiers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, AnalyticsModifier | None]:
|
||||
"""Return the analytics modifiers."""
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsInput:
|
||||
"""Analytics input for a single integration.
|
||||
|
||||
This is sent to integrations that implement the platform.
|
||||
"""
|
||||
|
||||
device_ids: Iterable[str] = field(default_factory=list)
|
||||
entity_ids: Iterable[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsModifications:
|
||||
"""Analytics config for a single integration.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
devices: Mapping[str, DeviceAnalyticsModifications] | None = None
|
||||
entities: Mapping[str, EntityAnalyticsModifications] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceAnalyticsModifications:
|
||||
"""Analytics config for a single device.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityAnalyticsModifications:
|
||||
"""Analytics config for a single entity.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
class AnalyticsPlatformProtocol(Protocol):
|
||||
"""Define the format of analytics platforms."""
|
||||
|
||||
async def async_modify_analytics(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
analytics_input: AnalyticsInput,
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
|
||||
|
||||
async def _async_get_analytics_platform(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsPlatformProtocol | None:
|
||||
"""Get analytics platform."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, domain)
|
||||
except IntegrationNotFound:
|
||||
return None
|
||||
try:
|
||||
return await integration.async_get_platform(DOMAIN)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_get_modifier(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsModifier | None:
|
||||
"""Get analytics modifier."""
|
||||
modifiers = _async_get_modifiers(hass)
|
||||
modifier = modifiers.get(domain, UNDEFINED)
|
||||
|
||||
if modifier is not UNDEFINED:
|
||||
return modifier
|
||||
|
||||
platform = await _async_get_analytics_platform(hass, domain)
|
||||
if platform is None:
|
||||
modifiers[domain] = None
|
||||
return None
|
||||
|
||||
modifier = getattr(platform, "async_modify_analytics", None)
|
||||
modifiers[domain] = modifier
|
||||
return modifier
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""Generate a new UUID."""
|
||||
@@ -393,17 +500,20 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
return domains
|
||||
|
||||
|
||||
DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
|
||||
DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices."""
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
continue
|
||||
@@ -416,27 +526,113 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[0].append(device_entry.id)
|
||||
|
||||
# Get entity list
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[1].append(entity_entry.entity_id)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integration_inputs.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
# Filter out custom integrations and integrations that are not device or hub type
|
||||
integration_inputs = {
|
||||
domain: integration_info
|
||||
for domain, integration_info in integration_inputs.items()
|
||||
if (integration := integrations.get(domain)) is not None
|
||||
and integration.is_built_in
|
||||
and integration.integration_type in ("device", "hub")
|
||||
}
|
||||
|
||||
# Call integrations that implement the analytics platform
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
if (
|
||||
modifier := await _async_get_modifier(hass, integration_domain)
|
||||
) is not None:
|
||||
try:
|
||||
integration_config = await modifier(
|
||||
hass, AnalyticsInput(*integration_input)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception(
|
||||
"Calling async_modify_analytics for integration '%s' failed: %s",
|
||||
integration_domain,
|
||||
err,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
integration_configs[integration_domain] = integration_config
|
||||
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
|
||||
devices_info = integration_info["devices"]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
for device_id in integration_input[0]:
|
||||
device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
|
||||
if integration_config.devices is not None:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
if device_config.remove:
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fill out via_device with new device ids
|
||||
for integration_info in integrations_info.values():
|
||||
@@ -445,10 +641,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
# Fill out information about entities
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
@@ -456,53 +657,49 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
devices_info = integration_info["devices"]
|
||||
entities_info = integration_info["entities"]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None,
|
||||
"capabilities": entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integrations_info.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
for domain, integration_info in integrations_info.items():
|
||||
if integration := integrations.get(domain):
|
||||
integration_info["is_custom_integration"] = not integration.is_built_in
|
||||
# Include version for custom integrations
|
||||
if not integration.is_built_in and integration.version:
|
||||
integration_info["custom_integration_version"] = str(
|
||||
integration.version
|
||||
for entity_id in integration_input[1]:
|
||||
entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
|
||||
if integration_config.entities is not None:
|
||||
entity_config = integration_config.entities.get(
|
||||
entity_id, entity_config
|
||||
)
|
||||
|
||||
if entity_config.remove:
|
||||
continue
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": (
|
||||
entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None
|
||||
),
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"home_assistant": HA_VERSION,
|
||||
|
@@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import (
|
||||
RequestLimitReached,
|
||||
WebsocketError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
BCU_APP,
|
||||
CHARGEPOINT_SETTINGS,
|
||||
CHARGEPOINT_STATUS,
|
||||
CHARGING_CARD_ID,
|
||||
DOMAIN,
|
||||
EVSE_ID,
|
||||
LOGGER,
|
||||
PLUG_AND_CHARGE,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
VALUE,
|
||||
)
|
||||
|
||||
@@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
CHARGE_CARDS = "CHARGE_CARDS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
@@ -41,6 +52,16 @@ GRID = "GRID"
|
||||
OBJECT = "object"
|
||||
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
# When no charging card is provided, use no charging card (BCU_APP = no charging card).
|
||||
vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
@@ -67,6 +88,66 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Blue Current."""
|
||||
|
||||
async def start_charge_session(service_call: ServiceCall) -> None:
|
||||
"""Start a charge session with the provided device and charge card ID."""
|
||||
# When no charge card is provided, use the default charge card set in the config flow.
|
||||
charging_card_id = service_call.data[CHARGING_CARD_ID]
|
||||
device_id = service_call.data[CONF_DEVICE_ID]
|
||||
|
||||
# Get the device based on the given device ID.
|
||||
device = dr.async_get(hass).devices.get(device_id)
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_device_id"
|
||||
)
|
||||
|
||||
blue_current_config_entry: ConfigEntry | None = None
|
||||
|
||||
for config_entry_id in device.config_entries:
|
||||
config_entry = hass.config_entries.async_get_entry(config_entry_id)
|
||||
if not config_entry or config_entry.domain != DOMAIN:
|
||||
# Not the blue_current config entry.
|
||||
continue
|
||||
|
||||
if config_entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
|
||||
)
|
||||
|
||||
blue_current_config_entry = config_entry
|
||||
break
|
||||
|
||||
if not blue_current_config_entry:
|
||||
# The device is not connected to a valid blue_current config entry.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="no_config_entry"
|
||||
)
|
||||
|
||||
connector = blue_current_config_entry.runtime_data
|
||||
|
||||
# Get the evse_id from the identifier of the device.
|
||||
evse_id = next(
|
||||
identifier[1]
|
||||
for identifier in device.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
)
|
||||
|
||||
await connector.client.start_session(evse_id, charging_card_id)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
start_charge_session,
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
) -> bool:
|
||||
@@ -87,6 +168,7 @@ class Connector:
|
||||
self.client = client
|
||||
self.charge_points: dict[str, dict] = {}
|
||||
self.grid: dict[str, Any] = {}
|
||||
self.charge_cards: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def on_data(self, message: dict) -> None:
|
||||
"""Handle received data."""
|
||||
|
@@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__)
|
||||
|
||||
EVSE_ID = "evse_id"
|
||||
MODEL_TYPE = "model_type"
|
||||
CARD = "card"
|
||||
UID = "uid"
|
||||
BCU_APP = "BCU-APP"
|
||||
WITHOUT_CHARGING_CARD = "without_charging_card"
|
||||
CHARGING_CARD_ID = "charging_card_id"
|
||||
SERVICE_START_CHARGE_SESSION = "start_charge_session"
|
||||
PLUG_AND_CHARGE = "plug_and_charge"
|
||||
VALUE = "value"
|
||||
PERMISSION = "permission"
|
||||
|
@@ -42,5 +42,10 @@
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
homeassistant/components/blue_current/services.yaml
Normal file
12
homeassistant/components/blue_current/services.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
start_charge_session:
|
||||
fields:
|
||||
device_id:
|
||||
selector:
|
||||
device:
|
||||
integration: blue_current
|
||||
required: true
|
||||
|
||||
charging_card_id:
|
||||
selector:
|
||||
text:
|
||||
required: false
|
@@ -22,6 +22,16 @@
|
||||
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"card": "Card"
|
||||
},
|
||||
"description": "Select the default charging card you want to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"activity": {
|
||||
@@ -136,5 +146,39 @@
|
||||
"name": "Block charge point"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"select_charging_card": {
|
||||
"options": {
|
||||
"without_charging_card": "Without charging card"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"name": "Start charge session",
|
||||
"description": "Starts a new charge session on a specified charge point.",
|
||||
"fields": {
|
||||
"charging_card_id": {
|
||||
"name": "Charging card ID",
|
||||
"description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
|
||||
},
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "The ID of the Blue Current charge point."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID given."
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"no_config_entry": {
|
||||
"message": "Device has not a valid blue_current config entry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothScannerDevice,
|
||||
@@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
|
||||
"""Return a HaBleakScannerWrapper.
|
||||
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
|
||||
"""Return a HaBleakScannerWrapper cast to BleakScanner.
|
||||
|
||||
This is a wrapper around our BleakScanner singleton that allows
|
||||
multiple integrations to share the same BleakScanner.
|
||||
|
||||
The wrapper is cast to BleakScanner for type compatibility with
|
||||
libraries expecting a BleakScanner instance.
|
||||
"""
|
||||
return HaBleakScannerWrapper()
|
||||
return cast(BleakScanner, HaBleakScannerWrapper())
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@@ -205,6 +205,7 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, BringActivityData]:
|
||||
"""Fetch activity data from bring."""
|
||||
self.lists = self.coordinator.lists
|
||||
|
||||
list_dict: dict[str, BringActivityData] = {}
|
||||
for lst in self.lists:
|
||||
|
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.activity.async_add_listener(add_entities)
|
||||
coordinator.data.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the activity event."""
|
||||
bring_list = self.coordinator.data[self._list_uuid]
|
||||
if (bring_list := self.coordinator.data.get(self._list_uuid)) is None:
|
||||
return
|
||||
last_event_triggered = self.state
|
||||
if bring_list.activity.timeline and (
|
||||
last_event_triggered is None
|
||||
|
@@ -25,7 +25,11 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
|
||||
return await cloud.payments.subscription_info()
|
||||
except PaymentsApiError as exception:
|
||||
_LOGGER.error("Failed to fetch subscription information - %s", exception)
|
||||
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
"A timeout of %s was reached while trying to fetch subscription information",
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
@@ -29,10 +29,23 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
|
@@ -29,10 +29,21 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[COVER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
|
@@ -27,10 +27,21 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[LIGHT])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==0.12.3"]
|
||||
}
|
||||
|
@@ -57,9 +57,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
dynamic-devices: done
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: no config or diagnostic entities
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final, cast
|
||||
|
||||
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -65,15 +65,24 @@ async def async_setup_bridge_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
entities: list[ComelitBridgeSensorEntity] = []
|
||||
for device in coordinator.data[OTHER].values():
|
||||
entities.extend(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[OTHER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[OTHER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
@@ -85,15 +94,24 @@ async def async_setup_vedo_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
entities: list[ComelitVedoSensorEntity] = []
|
||||
for device in coordinator.data["alarm_zones"].values():
|
||||
entities.extend(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
|
@@ -39,6 +39,25 @@ async def async_setup_entry(
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
known_devices: dict[str, set[int]] = {
|
||||
dev_type: set() for dev_type in (IRRIGATION, OTHER)
|
||||
}
|
||||
|
||||
def _check_device() -> None:
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
current_devices = set(coordinator.data[dev_type])
|
||||
new_devices = current_devices - known_devices[dev_type]
|
||||
if new_devices:
|
||||
known_devices[dev_type].update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
"""Switch device."""
|
||||
|
@@ -78,8 +78,8 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
climate_entities = []
|
||||
for device_id in coordinator.connector.devices:
|
||||
device = coordinator.connector.devices[device_id]
|
||||
for device_id in coordinator.connector.all_devices:
|
||||
device = coordinator.connector.all_devices[device_id]
|
||||
|
||||
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
|
||||
climate_entities.append(
|
||||
@@ -140,7 +140,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available and self.device_id in self.coordinator.connector.devices
|
||||
super().available
|
||||
and self.device_id in self.coordinator.connector.all_devices
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -40,4 +40,4 @@ class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance
|
||||
async def _async_update_data(self) -> dict[int, DeviceInstance]:
|
||||
"""Update data via library."""
|
||||
await self.connector.update_state(device_id=None) # Update all devices
|
||||
return self.connector.devices
|
||||
return self.connector.all_devices
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.2.1"]
|
||||
"requirements": ["compit-inext-api==0.3.1"]
|
||||
}
|
||||
|
@@ -8,7 +8,13 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
HomeAssistant,
|
||||
async_get_hass,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
@@ -30,6 +36,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@@ -140,6 +147,7 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -191,4 +199,20 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
@callback
|
||||
def unregister_trigger() -> None:
|
||||
"""Unregister the trigger."""
|
||||
self.triggers_details.remove(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
return unregister_trigger
|
||||
|
@@ -4,13 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import IO, Any, cast
|
||||
|
||||
@@ -53,6 +51,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
@@ -74,17 +73,16 @@ from .const import DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
@@ -110,14 +108,6 @@ class LanguageIntents:
|
||||
fuzzy_responses: FuzzyLanguageResponses | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerData:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SentenceTriggerResult:
|
||||
"""Result when matching a sentence trigger in an automation."""
|
||||
@@ -240,21 +230,23 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
|
||||
# Used to filter slot lists before intent matching
|
||||
self._exposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_trie: Trie | None = None
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self.trigger_sentences: list[TriggerData] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# LRU cache to avoid unnecessary intent matching
|
||||
self._intent_cache = IntentCache(capacity=128)
|
||||
|
||||
@@ -1198,8 +1190,8 @@ class DefaultAgent(ConversationEntity):
|
||||
fuzzy_responses=fuzzy_responses,
|
||||
)
|
||||
|
||||
@core.callback
|
||||
def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
|
||||
@callback
|
||||
def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None:
|
||||
"""Clear slot lists when a registry has changed."""
|
||||
# Two subscribers can be scheduled at same time
|
||||
_LOGGER.debug("Clearing slot lists")
|
||||
@@ -1369,22 +1361,14 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return response_template.async_render(response_args)
|
||||
|
||||
@core.callback
|
||||
def register_trigger(
|
||||
self,
|
||||
sentences: list[str],
|
||||
callback: TRIGGER_CALLBACK_TYPE,
|
||||
) -> core.CALLBACK_TYPE:
|
||||
"""Register a list of sentences that will trigger a callback when recognized."""
|
||||
trigger_data = TriggerData(sentences=sentences, callback=callback)
|
||||
self.trigger_sentences.append(trigger_data)
|
||||
@callback
|
||||
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
|
||||
"""Update triggers."""
|
||||
self._triggers_details = triggers_details
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
return functools.partial(self._unregister_trigger, trigger_data)
|
||||
|
||||
@core.callback
|
||||
def _rebuild_trigger_intents(self) -> None:
|
||||
"""Rebuild the HassIL intents object from the current trigger sentences."""
|
||||
intents_dict = {
|
||||
@@ -1393,8 +1377,8 @@ class DefaultAgent(ConversationEntity):
|
||||
# Use trigger data index as a virtual intent name for HassIL.
|
||||
# This works because the intents are rebuilt on every
|
||||
# register/unregister.
|
||||
str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
|
||||
for trigger_id, trigger_data in enumerate(self.trigger_sentences)
|
||||
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
|
||||
for trigger_id, trigger_details in enumerate(self._triggers_details)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1414,14 +1398,6 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
|
||||
|
||||
@core.callback
|
||||
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
|
||||
"""Unregister a set of trigger sentences."""
|
||||
self.trigger_sentences.remove(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
async def async_recognize_sentence_trigger(
|
||||
self, user_input: ConversationInput
|
||||
) -> SentenceTriggerResult | None:
|
||||
@@ -1430,7 +1406,7 @@ class DefaultAgent(ConversationEntity):
|
||||
Calls the registered callbacks if there's a match and returns a sentence
|
||||
trigger result.
|
||||
"""
|
||||
if not self.trigger_sentences:
|
||||
if not self._triggers_details:
|
||||
# No triggers registered
|
||||
return None
|
||||
|
||||
@@ -1475,7 +1451,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
|
@@ -169,12 +169,11 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
sentences = []
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
sentences.extend(trigger_data.sentences)
|
||||
for trigger_details in manager.triggers_details:
|
||||
sentences.extend(trigger_details.sentences)
|
||||
|
||||
connection.send_result(msg["id"], {"trigger_sentences": sentences})
|
||||
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
|
||||
}
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
@@ -24,6 +26,18 @@ from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerDetails:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
@@ -134,6 +148,6 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
return agent.register_trigger(sentences, call_action)
|
||||
return get_agent_manager(hass).register_trigger(
|
||||
TriggerDetails(sentences=sentences, callback=call_action)
|
||||
)
|
||||
|
58
homeassistant/components/cync/__init__.py
Normal file
58
homeassistant/components/cync/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""The Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycync import Auth, Cync, User
|
||||
from pycync.exceptions import AuthFailedError, CyncError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_USER_ID,
|
||||
)
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Set up Cync from a config entry."""
|
||||
user_info = User(
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
entry.data[CONF_REFRESH_TOKEN],
|
||||
entry.data[CONF_AUTHORIZE_STRING],
|
||||
entry.data[CONF_USER_ID],
|
||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||
)
|
||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||
|
||||
try:
|
||||
cync = await Cync.create(cync_auth)
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||
except CyncError as ex:
|
||||
raise ConfigEntryNotReady("Unable to connect to Cync") from ex
|
||||
|
||||
devices_coordinator = CyncCoordinator(hass, entry, cync)
|
||||
|
||||
cync.set_update_callback(devices_coordinator.on_data_update)
|
||||
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = devices_coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
cync = entry.runtime_data.cync
|
||||
await cync.shut_down()
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
118
homeassistant/components/cync/config_flow.py
Normal file
118
homeassistant/components/cync/config_flow.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Config flow for the Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycync import Auth
|
||||
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TWO_FACTOR_CODE,
|
||||
CONF_USER_ID,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str})
|
||||
|
||||
|
||||
class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cync."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
cync_auth: Auth
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with user credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login()
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_two_factor(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with the two factor auth code sent to the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
|
||||
"""Create the Cync config entry using input user data."""
|
||||
|
||||
cync_user = self.cync_auth.user
|
||||
await self.async_set_unique_id(str(cync_user.user_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config = {
|
||||
CONF_USER_ID: cync_user.user_id,
|
||||
CONF_AUTHORIZE_STRING: cync_user.authorize,
|
||||
CONF_EXPIRES_AT: cync_user.expires_at,
|
||||
CONF_ACCESS_TOKEN: cync_user.access_token,
|
||||
CONF_REFRESH_TOKEN: cync_user.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=user_email, data=config)
|
9
homeassistant/components/cync/const.py
Normal file
9
homeassistant/components/cync/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Constants for the Cync integration."""
|
||||
|
||||
DOMAIN = "cync"
|
||||
|
||||
CONF_TWO_FACTOR_CODE = "two_factor_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
CONF_AUTHORIZE_STRING = "authorize_string"
|
||||
CONF_EXPIRES_AT = "expires_at"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
87
homeassistant/components/cync/coordinator.py
Normal file
87
homeassistant/components/cync/coordinator.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Coordinator to handle keeping device states up to date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pycync import Cync, CyncDevice, User
|
||||
from pycync.exceptions import AuthFailedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CyncConfigEntry = ConfigEntry[CyncCoordinator]
|
||||
|
||||
|
||||
class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]):
|
||||
"""Coordinator to handle updating Cync device states."""
|
||||
|
||||
config_entry: CyncConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync
|
||||
) -> None:
|
||||
"""Initialize the Cync coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Cync Data Coordinator",
|
||||
config_entry=config_entry,
|
||||
update_interval=timedelta(seconds=30),
|
||||
always_update=True,
|
||||
)
|
||||
self.cync = cync
|
||||
|
||||
async def on_data_update(self, data: dict[int, CyncDevice]) -> None:
|
||||
"""Update registered devices with new data."""
|
||||
merged_data = self.data | data if self.data else data
|
||||
self.async_set_updated_data(merged_data)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator with initial device states."""
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]:
|
||||
await self._update_config_cync_credentials(logged_in_user)
|
||||
|
||||
async def _async_update_data(self) -> dict[int, CyncDevice]:
|
||||
"""First, refresh the user's auth token if it is set to expire in less than one hour.
|
||||
|
||||
Then, fetch all current device states.
|
||||
"""
|
||||
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.expires_at - time.time() < 3600:
|
||||
await self._async_refresh_cync_credentials()
|
||||
|
||||
self.cync.update_device_states()
|
||||
current_device_states = self.cync.get_devices()
|
||||
|
||||
return {device.device_id: device for device in current_device_states}
|
||||
|
||||
async def _async_refresh_cync_credentials(self) -> None:
|
||||
"""Attempt to refresh the Cync user's authentication token."""
|
||||
|
||||
try:
|
||||
refreshed_user = await self.cync.refresh_credentials()
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("Unable to refresh user token") from ex
|
||||
else:
|
||||
await self._update_config_cync_credentials(refreshed_user)
|
||||
|
||||
async def _update_config_cync_credentials(self, user_info: User) -> None:
|
||||
"""Update the config entry with current user info."""
|
||||
|
||||
new_data = {**self.config_entry.data}
|
||||
new_data[CONF_ACCESS_TOKEN] = user_info.access_token
|
||||
new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token
|
||||
new_data[CONF_EXPIRES_AT] = user_info.expires_at
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
|
45
homeassistant/components/cync/entity.py
Normal file
45
homeassistant/components/cync/entity.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Setup for a generic entity type for the Cync integration."""
|
||||
|
||||
from pycync.devices import CyncDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CyncCoordinator
|
||||
|
||||
|
||||
class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]):
|
||||
"""Generic base entity for Cync devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncDevice,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._cync_device_id = device.device_id
|
||||
self._attr_unique_id = device.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
manufacturer="GE Lighting",
|
||||
name=device.name,
|
||||
suggested_area=room_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Determines whether this device is currently available."""
|
||||
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._cync_device_id in self.coordinator.data
|
||||
and self.coordinator.data[self._cync_device_id].is_online
|
||||
)
|
180
homeassistant/components/cync/light.py
Normal file
180
homeassistant/components/cync/light.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Support for Cync light entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pycync import CyncLight
|
||||
from pycync.devices.capabilities import CyncCapability
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import value_to_brightness
|
||||
from homeassistant.util.scaling import scale_ranged_value_to_int_range
|
||||
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
from .entity import CyncBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CyncConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cync lights from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
cync = coordinator.cync
|
||||
|
||||
entities_to_add = []
|
||||
|
||||
for home in cync.get_homes():
|
||||
for room in home.rooms:
|
||||
room_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for device in room.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(room_lights)
|
||||
|
||||
group_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for group in room.groups
|
||||
for device in group.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(group_lights)
|
||||
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
|
||||
class CyncLightEntity(CyncBaseEntity, LightEntity):
|
||||
"""Representation of a Cync light."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_min_color_temp_kelvin = 2000
|
||||
_attr_max_color_temp_kelvin = 7000
|
||||
_attr_translation_key = "light"
|
||||
_attr_name = None
|
||||
|
||||
BRIGHTNESS_SCALE = (0, 100)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncLight,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Set up base attributes."""
|
||||
super().__init__(device, coordinator, room_name)
|
||||
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
if device.supports_capability(CyncCapability.CCT_COLOR):
|
||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if device.supports_capability(CyncCapability.DIMMING):
|
||||
supported_color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if device.supports_capability(CyncCapability.RGB_COLOR):
|
||||
supported_color_modes.add(ColorMode.RGB)
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(
|
||||
supported_color_modes
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the light is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Provide the light's current brightness."""
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return color temperature in kelvin."""
|
||||
return scale_ranged_value_to_int_range(
|
||||
(1, 100),
|
||||
(self.min_color_temp_kelvin, self.max_color_temp_kelvin),
|
||||
self._device.color_temp,
|
||||
)
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
"""Provide the light's current color in RGB format."""
|
||||
return self._device.rgb
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.CCT_COLOR)
|
||||
and self._device.color_mode > 0
|
||||
and self._device.color_mode <= 100
|
||||
):
|
||||
return ColorMode.COLOR_TEMP
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.RGB_COLOR)
|
||||
and self._device.color_mode == 254
|
||||
):
|
||||
return ColorMode.RGB
|
||||
if self._device.supports_capability(CyncCapability.DIMMING):
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
return ColorMode.ONOFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Process an action on the light."""
|
||||
if not kwargs:
|
||||
await self._device.turn_on()
|
||||
|
||||
elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
converted_color_temp = self._normalize_color_temp(color_temp)
|
||||
|
||||
await self._device.set_color_temp(converted_color_temp)
|
||||
elif kwargs.get(ATTR_RGB_COLOR) is not None:
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
|
||||
await self._device.set_rgb(rgb)
|
||||
elif kwargs.get(ATTR_BRIGHTNESS) is not None:
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
converted_brightness = self._normalize_brightness(brightness)
|
||||
|
||||
await self._device.set_brightness(converted_brightness)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self._device.turn_off()
|
||||
|
||||
def _normalize_brightness(self, brightness: float | None) -> int | None:
|
||||
"""Return calculated brightness value scaled between 0-100."""
|
||||
if brightness is not None:
|
||||
return int((brightness / 255) * 100)
|
||||
|
||||
return None
|
||||
|
||||
def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None:
|
||||
"""Return calculated color temp value scaled between 1-100."""
|
||||
if color_temp_kelvin is not None:
|
||||
kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin
|
||||
scaled_kelvin = int(
|
||||
((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100
|
||||
)
|
||||
if scaled_kelvin == 0:
|
||||
scaled_kelvin += 1
|
||||
|
||||
return scaled_kelvin
|
||||
return None
|
||||
|
||||
@property
|
||||
def _device(self) -> CyncLight:
|
||||
"""Fetch the reference to the backing Cync light for this device."""
|
||||
|
||||
return self.coordinator.data[self._cync_device_id]
|
11
homeassistant/components/cync/manifest.json
Normal file
11
homeassistant/components/cync/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "cync",
|
||||
"name": "Cync",
|
||||
"codeowners": ["@Kinachi249"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cync",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.0"]
|
||||
}
|
69
homeassistant/components/cync/quality_scale.yaml
Normal file
69
homeassistant/components/cync/quality_scale.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
32
homeassistant/components/cync/strings.json
Normal file
32
homeassistant/components/cync/strings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Your Cync account's email address",
|
||||
"password": "Your Cync account's password"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"data": {
|
||||
"two_factor_code": "Two-factor code"
|
||||
},
|
||||
"data_description": {
|
||||
"two_factor_code": "The two-factor code sent to your Cync account's email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -9,6 +9,7 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"file",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.2"],
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.7.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
|
||||
}
|
||||
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deebot_client.capabilities import CapabilitySet
|
||||
from deebot_client.capabilities import CapabilityNumber, CapabilitySet
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.water_info import WaterCustomAmountEvent
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
@@ -75,6 +77,19 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = (
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
EcovacsNumberEntityDescription[WaterCustomAmountEvent](
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilityNumber)
|
||||
else None
|
||||
),
|
||||
value_fn=lambda e: e.value,
|
||||
key="water_amount",
|
||||
translation_key="water_amount",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -100,6 +115,18 @@ class EcovacsNumberEntity[EventT: Event](
|
||||
|
||||
entity_description: EcovacsNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilitySet[EventT, [int]],
|
||||
entity_description: EcovacsNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, entity_description)
|
||||
if isinstance(capability, CapabilityNumber):
|
||||
self._attr_native_min_value = capability.min
|
||||
self._attr_native_max_value = capability.max
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
@@ -33,7 +33,11 @@ class EcovacsSelectEntityDescription[EventT: Event](
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
|
||||
EcovacsSelectEntityDescription[WaterAmountEvent](
|
||||
capability_fn=lambda caps: caps.water.amount if caps.water else None,
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilitySetTypes)
|
||||
else None
|
||||
),
|
||||
current_option_fn=lambda e: get_name_key(e.value),
|
||||
options_fn=lambda water: [get_name_key(amount) for amount in water.types],
|
||||
key="water_amount",
|
||||
|
@@ -102,6 +102,9 @@
|
||||
},
|
||||
"volume": {
|
||||
"name": "Volume"
|
||||
},
|
||||
"water_amount": {
|
||||
"name": "Water flow level"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -152,8 +155,10 @@
|
||||
"station_state": {
|
||||
"name": "Station state",
|
||||
"state": {
|
||||
"drying_mop": "Drying mop",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"emptying_dustbin": "Emptying dustbin"
|
||||
"emptying_dustbin": "Emptying dustbin",
|
||||
"washing_mop": "Washing mop"
|
||||
}
|
||||
},
|
||||
"stats_area": {
|
||||
@@ -174,7 +179,7 @@
|
||||
},
|
||||
"select": {
|
||||
"water_amount": {
|
||||
"name": "Water flow level",
|
||||
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
|
@@ -7,8 +7,6 @@ import random
|
||||
import string
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deebot_client.events.station import State
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
@@ -49,9 +47,6 @@ def get_supported_entities(
|
||||
@callback
|
||||
def get_name_key(enum: Enum) -> str:
|
||||
"""Return the lower case name of the enum."""
|
||||
if enum is State.EMPTYING:
|
||||
# Will be fixed in the next major release of deebot-client
|
||||
return "emptying_dustbin"
|
||||
return enum.name.lower()
|
||||
|
||||
|
||||
|
24
homeassistant/components/ekeybionyx/__init__.py
Normal file
24
homeassistant/components/ekeybionyx/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""The Ekey Bionyx integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
|
||||
type EkeyBionyxConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Set up the Ekey Bionyx config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
@@ -0,0 +1,14 @@
|
||||
"""application_credentials platform the Ekey Bionyx integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
271
homeassistant/components/ekeybionyx/config_flow.py
Normal file
271
homeassistant/components/ekeybionyx/config_flow.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Config flow for ekey bionyx."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import aiohttp
|
||||
import ekey_bionyxpy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.webhook import (
|
||||
async_generate_id as webhook_generate_id,
|
||||
async_generate_path as webhook_generate_path,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector
|
||||
|
||||
from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
|
||||
|
||||
# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot
|
||||
VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
|
||||
|
||||
|
||||
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
|
||||
"""ekey bionyx authentication before a ConfigEntry exists.
|
||||
|
||||
This implementation directly provides the token without supporting refresh.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: aiohttp.ClientSession,
|
||||
token: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize ConfigFlowEkeyApi."""
|
||||
super().__init__(websession, API_URL)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the token for the Ekey API."""
|
||||
return self._token["access_token"]
|
||||
|
||||
|
||||
class EkeyFlowData(TypedDict):
|
||||
"""Type for Flow Data."""
|
||||
|
||||
api: NotRequired[ekey_bionyxpy.BionyxAPI]
|
||||
system: NotRequired[ekey_bionyxpy.System]
|
||||
systems: NotRequired[list[ekey_bionyxpy.System]]
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle ekey bionyx OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
check_deletion_task: asyncio.Task[None] | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize OAuth2FlowHandler."""
|
||||
super().__init__()
|
||||
self._data: EkeyFlowData = {}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": SCOPE}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Start the user facing flow by initializing the API and getting the systems."""
|
||||
client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN])
|
||||
ap = ekey_bionyxpy.BionyxAPI(client)
|
||||
self._data["api"] = ap
|
||||
try:
|
||||
system_res = await ap.get_systems()
|
||||
except aiohttp.ClientResponseError:
|
||||
return self.async_abort(
|
||||
reason="cannot_connect",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
system = [s for s in system_res if s.own_system]
|
||||
if len(system) == 0:
|
||||
return self.async_abort(reason="no_own_systems")
|
||||
self._data["systems"] = system
|
||||
if len(system) == 1:
|
||||
# skipping choose_system since there is only one
|
||||
self._data["system"] = system[0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
return await self.async_step_choose_system(user_input=None)
|
||||
|
||||
async def async_step_choose_system(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to choose System if multiple systems are present."""
|
||||
if user_input is None:
|
||||
options: list[SelectOptionDict] = [
|
||||
{"value": s.system_id, "label": s.system_name}
|
||||
for s in self._data["systems"]
|
||||
]
|
||||
data_schema = {vol.Required("system"): SelectSelector({"options": options})}
|
||||
return self.async_show_form(
|
||||
step_id="choose_system",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
self._data["system"] = [
|
||||
s for s in self._data["systems"] if s.system_id == user_input["system"]
|
||||
][0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
|
||||
async def async_step_check_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Check if system has open webhooks."""
|
||||
system = self._data["system"]
|
||||
await self.async_set_unique_id(system.system_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if (
|
||||
system.function_webhook_quotas["free"] == 0
|
||||
and system.function_webhook_quotas["used"] == 0
|
||||
):
|
||||
return self.async_abort(
|
||||
reason="no_available_webhooks",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
|
||||
if system.function_webhook_quotas["used"] > 0:
|
||||
return await self.async_step_delete_webhooks()
|
||||
return await self.async_step_webhooks(user_input=None)
|
||||
|
||||
async def async_step_webhooks(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to setup webhooks."""
|
||||
system = self._data["system"]
|
||||
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input is not None:
|
||||
errors = {}
|
||||
for key, webhook_name in user_input.items():
|
||||
if key == CONF_URL:
|
||||
continue
|
||||
if not re.match(VALID_NAME_PATTERN, webhook_name):
|
||||
errors.update({key: "invalid_name"})
|
||||
try:
|
||||
cv.url(user_input[CONF_URL])
|
||||
except vol.Invalid:
|
||||
errors[CONF_URL] = "invalid_url"
|
||||
if set(user_input) == {CONF_URL}:
|
||||
errors["base"] = "no_webhooks_provided"
|
||||
|
||||
if not errors:
|
||||
webhook_data = [
|
||||
{
|
||||
"auth": secrets.token_hex(32),
|
||||
"name": webhook_name,
|
||||
"webhook_id": webhook_generate_id(),
|
||||
}
|
||||
for key, webhook_name in user_input.items()
|
||||
if key != CONF_URL
|
||||
]
|
||||
for webhook in webhook_data:
|
||||
wh_def: ekey_bionyxpy.WebhookData = {
|
||||
"integrationName": "Home Assistant",
|
||||
"functionName": webhook["name"],
|
||||
"locationName": "Home Assistant",
|
||||
"definition": {
|
||||
"url": user_input[CONF_URL]
|
||||
+ webhook_generate_path(webhook["webhook_id"]),
|
||||
"authentication": {"apiAuthenticationType": "None"},
|
||||
"securityLevel": "AllowHttp",
|
||||
"method": "Post",
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"content": json.dumps({"auth": webhook["auth"]}),
|
||||
},
|
||||
},
|
||||
}
|
||||
webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id
|
||||
return self.async_create_entry(
|
||||
title=self._data["system"].system_name,
|
||||
data={"webhooks": webhook_data},
|
||||
)
|
||||
|
||||
data_schema: dict[Any, Any] = {
|
||||
vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50))
|
||||
for i in range(self._data["system"].function_webhook_quotas["free"])
|
||||
}
|
||||
data_schema[vol.Required(CONF_URL)] = str
|
||||
return self.async_show_form(
|
||||
step_id="webhooks",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(data_schema),
|
||||
{
|
||||
CONF_URL: get_url(
|
||||
self.hass,
|
||||
allow_ip=True,
|
||||
prefer_external=False,
|
||||
)
|
||||
}
|
||||
| (user_input or {}),
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"webhooks_available": str(
|
||||
self._data["system"].function_webhook_quotas["free"]
|
||||
),
|
||||
"ekeybionyx": INTEGRATION_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_delete_webhooks(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Form to delete Webhooks."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="delete_webhooks")
|
||||
for webhook in await self._data["system"].get_webhooks():
|
||||
await webhook.delete()
|
||||
return await self.async_step_wait_for_deletion(user_input=None)
|
||||
|
||||
async def async_step_wait_for_deletion(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for webhooks to be deleted in another flow."""
|
||||
uncompleted_task: asyncio.Task[None] | None = None
|
||||
|
||||
if not self.check_deletion_task:
|
||||
self.check_deletion_task = self.hass.async_create_task(
|
||||
self.async_check_deletion_status()
|
||||
)
|
||||
if not self.check_deletion_task.done():
|
||||
progress_action = "check_deletion_status"
|
||||
uncompleted_task = self.check_deletion_task
|
||||
if uncompleted_task:
|
||||
return self.async_show_progress(
|
||||
step_id="wait_for_deletion",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
progress_action=progress_action,
|
||||
progress_task=uncompleted_task,
|
||||
)
|
||||
self.check_deletion_task = None
|
||||
return self.async_show_progress_done(next_step_id="webhooks")
|
||||
|
||||
async def async_check_deletion_status(self) -> None:
|
||||
"""Check if webhooks have been deleted."""
|
||||
while True:
|
||||
self._data["systems"] = await self._data["api"].get_systems()
|
||||
self._data["system"] = [
|
||||
s
|
||||
for s in self._data["systems"]
|
||||
if s.system_id == self._data["system"].system_id
|
||||
][0]
|
||||
if self._data["system"].function_webhook_quotas["used"] == 0:
|
||||
break
|
||||
await asyncio.sleep(5)
|
13
homeassistant/components/ekeybionyx/const.py
Normal file
13
homeassistant/components/ekeybionyx/const.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Constants for the Ekey Bionyx integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "ekeybionyx"
|
||||
INTEGRATION_NAME = "ekey bionyx"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize"
|
||||
OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token"
|
||||
API_URL = "https://api.bionyx.io/3rd-party/api"
|
||||
SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access"
|
70
homeassistant/components/ekeybionyx/event.py
Normal file
70
homeassistant/components/ekeybionyx/event.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Event platform for ekey bionyx integration."""
|
||||
|
||||
from aiohttp.hdrs import METH_POST
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EkeyBionyxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EkeyBionyxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Ekey event."""
|
||||
async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"])
|
||||
|
||||
|
||||
class EkeyEvent(EventEntity):
|
||||
"""Ekey Event."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_event_types = ["event happened"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict[str, str],
|
||||
) -> None:
|
||||
"""Initialise a Ekey event entity."""
|
||||
self._attr_name = data["name"]
|
||||
self._attr_unique_id = data["ekey_id"]
|
||||
self._webhook_id = data["webhook_id"]
|
||||
self._auth = data["auth"]
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the webhook event."""
|
||||
self._trigger_event("event happened")
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks with your device API/library."""
|
||||
|
||||
async def async_webhook_handler(
|
||||
hass: HomeAssistant, webhook_id: str, request: Request
|
||||
) -> Response | None:
|
||||
if (await request.json())["auth"] == self._auth:
|
||||
self._async_handle_event()
|
||||
return None
|
||||
|
||||
webhook_register(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"Ekey {self._attr_name}",
|
||||
self._webhook_id,
|
||||
async_webhook_handler,
|
||||
allowed_methods=[METH_POST],
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister Webhook."""
|
||||
webhook_unregister(self.hass, self._webhook_id)
|
11
homeassistant/components/ekeybionyx/manifest.json
Normal file
11
homeassistant/components/ekeybionyx/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "ekeybionyx",
|
||||
"name": "ekey bionyx",
|
||||
"codeowners": ["@richardpolzer"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ekeybionyx",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ekey-bionyxpy==1.0.0"]
|
||||
}
|
92
homeassistant/components/ekeybionyx/quality_scale.yaml
Normal file
92
homeassistant/components/ekeybionyx/quality_scale.yaml
Normal file
@@ -0,0 +1,92 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not store the tokens.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration has no entities that should be disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
66
homeassistant/components/ekeybionyx/strings.json
Normal file
66
homeassistant/components/ekeybionyx/strings.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"choose_system": {
|
||||
"data": {
|
||||
"system": "System"
|
||||
},
|
||||
"data_description": {
|
||||
"system": "System the event entities should be set up for."
|
||||
},
|
||||
"description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant."
|
||||
},
|
||||
"webhooks": {
|
||||
"description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.",
|
||||
"data": {
|
||||
"webhook1": "Event entity 1",
|
||||
"webhook2": "Event entity 2",
|
||||
"webhook3": "Event entity 3",
|
||||
"webhook4": "Event entity 4",
|
||||
"webhook5": "Event entity 5",
|
||||
"url": "Home Assistant URL"
|
||||
},
|
||||
"data_description": {
|
||||
"webhook1": "Name of event entity 1 that will be mapped into a function",
|
||||
"webhook2": "Name of event entity 2 that will be mapped into a function",
|
||||
"webhook3": "Name of event entity 3 that will be mapped into a function",
|
||||
"webhook4": "Name of event entity 4 that will be mapped into a function",
|
||||
"webhook5": "Name of event entity 5 that will be mapped into a function",
|
||||
"url": "Home Assistant instance URL which can be reached from the fingerprint controller"
|
||||
}
|
||||
},
|
||||
"delete_webhooks": {
|
||||
"description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions."
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Name is invalid",
|
||||
"invalid_url": "URL is invalid",
|
||||
"no_webhooks_provided": "No event names provided"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.",
|
||||
"no_own_systems": "Your account does not have admin access to any systems.",
|
||||
"cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -57,6 +57,7 @@ from .manager import async_replace_device
|
||||
|
||||
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
|
||||
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
|
||||
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
@@ -137,7 +138,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = ""
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
if error == ERROR_INVALID_PASSWORD_AUTH or (
|
||||
error is None and self._device_info and self._device_info.uses_password
|
||||
):
|
||||
return await self.async_step_authenticate()
|
||||
|
||||
if error is None and entry_data.get(CONF_NOISE_PSK):
|
||||
# Device was configured with encryption but now connects without it.
|
||||
# Check if it's the same device before offering to remove encryption.
|
||||
if self._reauth_entry.unique_id and self._device_mac:
|
||||
expected_mac = format_mac(self._reauth_entry.unique_id)
|
||||
actual_mac = format_mac(self._device_mac)
|
||||
if expected_mac != actual_mac:
|
||||
# Different device at the same IP - do not offer to remove encryption
|
||||
return self._async_abort_wrong_device(
|
||||
self._reauth_entry, expected_mac, actual_mac
|
||||
)
|
||||
return await self.async_step_reauth_encryption_removed_confirm()
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -508,6 +524,28 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_DEVICE_NAME: self._device_name,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _async_abort_wrong_device(
|
||||
self, entry: ConfigEntry, expected_mac: str, actual_mac: str
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort flow because a different device was found at the IP address."""
|
||||
assert self._host is not None
|
||||
assert self._device_name is not None
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
reason = "reconfigure_unique_id_changed"
|
||||
else:
|
||||
reason = "reauth_unique_id_changed"
|
||||
return self.async_abort(
|
||||
reason=reason,
|
||||
description_placeholders={
|
||||
"name": entry.data.get(CONF_DEVICE_NAME, entry.title),
|
||||
"host": self._host,
|
||||
"expected_mac": expected_mac,
|
||||
"unexpected_mac": actual_mac,
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_validated_connection(self) -> ConfigFlowResult:
|
||||
"""Handle validated connection."""
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
@@ -539,17 +577,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
# Reauth was triggered a while ago, and since than
|
||||
# a new device resides at the same IP address.
|
||||
assert self._device_name is not None
|
||||
return self.async_abort(
|
||||
reason="reauth_unique_id_changed",
|
||||
description_placeholders={
|
||||
"name": self._reauth_entry.data.get(
|
||||
CONF_DEVICE_NAME, self._reauth_entry.title
|
||||
),
|
||||
"host": self._host,
|
||||
"expected_mac": format_mac(self._reauth_entry.unique_id),
|
||||
"unexpected_mac": format_mac(self.unique_id),
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
return self._async_abort_wrong_device(
|
||||
self._reauth_entry,
|
||||
format_mac(self._reauth_entry.unique_id),
|
||||
format_mac(self.unique_id),
|
||||
)
|
||||
|
||||
async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
|
||||
@@ -589,17 +620,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
|
||||
self._entry_with_name_conflict = self._reconfig_entry
|
||||
return await self.async_step_name_conflict()
|
||||
return self.async_abort(
|
||||
reason="reconfigure_unique_id_changed",
|
||||
description_placeholders={
|
||||
"name": self._reconfig_entry.data.get(
|
||||
CONF_DEVICE_NAME, self._reconfig_entry.title
|
||||
),
|
||||
"host": self._host,
|
||||
"expected_mac": format_mac(self._reconfig_entry.unique_id),
|
||||
"unexpected_mac": format_mac(self.unique_id),
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
return self._async_abort_wrong_device(
|
||||
self._reconfig_entry,
|
||||
format_mac(self._reconfig_entry.unique_id),
|
||||
format_mac(self.unique_id),
|
||||
)
|
||||
|
||||
async def async_step_encryption_key(
|
||||
@@ -672,13 +696,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
cli = APIClient(
|
||||
host,
|
||||
port or DEFAULT_PORT,
|
||||
"",
|
||||
self._password or "",
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
)
|
||||
try:
|
||||
await cli.connect()
|
||||
self._device_info = await cli.device_info()
|
||||
except InvalidAuthAPIError:
|
||||
return ERROR_INVALID_PASSWORD_AUTH
|
||||
except RequiresEncryptionAPIError:
|
||||
return ERROR_REQUIRES_ENCRYPTION_KEY
|
||||
except InvalidEncryptionKeyAPIError as ex:
|
||||
|
@@ -49,11 +49,13 @@ from aioesphomeapi import (
|
||||
from aioesphomeapi.model import ButtonInfo
|
||||
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import discovery_flow, entity_registry as er
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -468,7 +470,7 @@ class RuntimeEntryData:
|
||||
|
||||
@callback
|
||||
def async_on_connect(
|
||||
self, device_info: DeviceInfo, api_version: APIVersion
|
||||
self, hass: HomeAssistant, device_info: DeviceInfo, api_version: APIVersion
|
||||
) -> None:
|
||||
"""Call when the entry has been connected."""
|
||||
self.available = True
|
||||
@@ -484,6 +486,29 @@ class RuntimeEntryData:
|
||||
# be marked as unavailable or not.
|
||||
self.expected_disconnect = True
|
||||
|
||||
if not device_info.zwave_proxy_feature_flags:
|
||||
return
|
||||
|
||||
assert self.client.connected_address
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
"zwave_js",
|
||||
{"source": config_entries.SOURCE_ESPHOME},
|
||||
ESPHomeServiceInfo(
|
||||
name=device_info.name,
|
||||
zwave_home_id=device_info.zwave_home_id or None,
|
||||
ip_address=self.client.connected_address,
|
||||
port=self.client.port,
|
||||
noise_psk=self.client.noise_psk,
|
||||
),
|
||||
discovery_key=discovery_flow.DiscoveryKey(
|
||||
domain=DOMAIN,
|
||||
key=device_info.mac_address,
|
||||
version=1,
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_register_assist_satellite_config_updated_callback(
|
||||
self,
|
||||
|
@@ -372,6 +372,9 @@ class ESPHomeManager:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
try:
|
||||
await self._on_connect()
|
||||
except InvalidAuthAPIError as err:
|
||||
_LOGGER.warning("Authentication failed for %s: %s", self.host, err)
|
||||
await self._start_reauth_and_disconnect()
|
||||
except APIConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Error getting setting up connection for %s: %s", self.host, err
|
||||
@@ -505,7 +508,7 @@ class ESPHomeManager:
|
||||
|
||||
api_version = cli.api_version
|
||||
assert api_version is not None, "API version must be set"
|
||||
entry_data.async_on_connect(device_info, api_version)
|
||||
entry_data.async_on_connect(hass, device_info, api_version)
|
||||
|
||||
await self._handle_dynamic_encryption_key(device_info)
|
||||
|
||||
@@ -641,7 +644,14 @@ class ESPHomeManager:
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
return
|
||||
await self._start_reauth_and_disconnect()
|
||||
|
||||
async def _start_reauth_and_disconnect(self) -> None:
|
||||
"""Start reauth flow and stop reconnection attempts."""
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
await self.cli.disconnect()
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
|
||||
async def _handle_dynamic_encryption_key(
|
||||
self, device_info: EsphomeDeviceInfo
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.6.0",
|
||||
"aioesphomeapi==41.9.4",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
@@ -194,6 +194,21 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)]
|
||||
|
||||
option = self._attr_current_option
|
||||
|
||||
if (
|
||||
(self._wake_word_index == 0)
|
||||
and (len(config.active_wake_words) == 1)
|
||||
and (option in (None, NO_WAKE_WORD))
|
||||
):
|
||||
option = next(
|
||||
(
|
||||
wake_word
|
||||
for wake_word, wake_word_id in self._wake_words.items()
|
||||
if wake_word_id == config.active_wake_words[0]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if (
|
||||
(option is None)
|
||||
or ((wake_word_id := self._wake_words.get(option)) is None)
|
||||
|
@@ -7,11 +7,22 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_register_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the file component."""
|
||||
async_register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a file component entry."""
|
||||
|
@@ -6,3 +6,7 @@ CONF_TIMESTAMP = "timestamp"
|
||||
|
||||
DEFAULT_NAME = "File"
|
||||
FILE_ICON = "mdi:file"
|
||||
|
||||
SERVICE_READ_FILE = "read_file"
|
||||
ATTR_FILE_NAME = "file_name"
|
||||
ATTR_FILE_ENCODING = "file_encoding"
|
||||
|
7
homeassistant/components/file/icons.json
Normal file
7
homeassistant/components/file/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"read_file": {
|
||||
"service": "mdi:file"
|
||||
}
|
||||
}
|
||||
}
|
88
homeassistant/components/file/services.py
Normal file
88
homeassistant/components/file/services.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""File Service calls."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
|
||||
|
||||
|
||||
def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for File integration."""
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
read_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_FILE_NAME): cv.string,
|
||||
vol.Required(ATTR_FILE_ENCODING): cv.string,
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = {
|
||||
"json": (json.loads, json.JSONDecodeError),
|
||||
"yaml": (yaml.safe_load, yaml.YAMLError),
|
||||
}
|
||||
|
||||
|
||||
def read_file(call: ServiceCall) -> dict:
|
||||
"""Handle read_file service call."""
|
||||
file_name = call.data[ATTR_FILE_NAME]
|
||||
file_encoding = call.data[ATTR_FILE_ENCODING].lower()
|
||||
|
||||
if not call.hass.config.is_allowed_path(file_name):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_access_to_path",
|
||||
translation_placeholders={"filename": file_name},
|
||||
)
|
||||
|
||||
if file_encoding not in ENCODING_LOADERS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_file_encoding",
|
||||
translation_placeholders={
|
||||
"filename": file_name,
|
||||
"encoding": file_encoding,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_name, encoding="utf-8") as file:
|
||||
file_content = file.read()
|
||||
except FileNotFoundError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_not_found",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
except OSError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_read_error",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
|
||||
loader, error_type = ENCODING_LOADERS[file_encoding]
|
||||
try:
|
||||
data = loader(file_content)
|
||||
except error_type as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_decoding",
|
||||
translation_placeholders={"filename": file_name, "encoding": file_encoding},
|
||||
) from err
|
||||
|
||||
return {"data": data}
|
14
homeassistant/components/file/services.yaml
Normal file
14
homeassistant/components/file/services.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Describes the format for available file services
|
||||
read_file:
|
||||
fields:
|
||||
file_name:
|
||||
example: "www/my_file.json"
|
||||
selector:
|
||||
text:
|
||||
file_encoding:
|
||||
example: "JSON"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "JSON"
|
||||
- "YAML"
|
@@ -64,6 +64,37 @@
|
||||
},
|
||||
"write_access_failed": {
|
||||
"message": "Write access to {filename} failed: {exc}."
|
||||
},
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"unsupported_file_encoding": {
|
||||
"message": "Cannot read {filename}, unsupported file encoding {encoding}."
|
||||
},
|
||||
"file_decoding": {
|
||||
"message": "Cannot read file {filename} as {encoding}."
|
||||
},
|
||||
"file_not_found": {
|
||||
"message": "File {filename} not found."
|
||||
},
|
||||
"file_read_error": {
|
||||
"message": "Error reading {filename}."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_file": {
|
||||
"name": "Read file",
|
||||
"description": "Reads a file and returns the contents.",
|
||||
"fields": {
|
||||
"file_name": {
|
||||
"name": "File name",
|
||||
"description": "Name of the file to read."
|
||||
},
|
||||
"file_encoding": {
|
||||
"name": "File encoding",
|
||||
"description": "Encoding of the file (JSON, YAML.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250903.5"]
|
||||
"requirements": ["home-assistant-frontend==20250925.0"]
|
||||
}
|
||||
|
@@ -73,6 +73,7 @@ from . import ( # noqa: F401
|
||||
config_flow,
|
||||
diagnostics,
|
||||
sensor,
|
||||
switch,
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
@@ -149,7 +150,7 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
|
||||
# If new platforms are added, be sure to import them above
|
||||
# so we do not make other components that depend on hassio
|
||||
# wait for the import of the platforms
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
|
||||
CONF_FRONTEND_REPO = "development_repo"
|
||||
|
||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -545,3 +546,15 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
await super()._async_refresh(
|
||||
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
|
||||
)
|
||||
|
||||
async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
|
||||
"""Force refresh of addon info data for a specific addon."""
|
||||
try:
|
||||
slug, info = await self._update_addon_info(addon_slug)
|
||||
if info is not None and DATA_KEY_ADDONS in self.data:
|
||||
if slug in self.data[DATA_KEY_ADDONS]:
|
||||
data = deepcopy(self.data)
|
||||
data[DATA_KEY_ADDONS][slug].update(info)
|
||||
self.async_set_updated_data(data)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.2"],
|
||||
"requirements": ["aiohasupervisor==0.3.3b0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -250,6 +250,10 @@
|
||||
"unsupported_os_version": {
|
||||
"title": "Unsupported system - Home Assistant OS version",
|
||||
"description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more."
|
||||
},
|
||||
"unsupported_home_assistant_core_version": {
|
||||
"title": "Unsupported system - Home Assistant Core version",
|
||||
"description": "System is unsupported because the Home Assistant Core version in use is not supported. For troubleshooting information, select Learn more."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
90
homeassistant/components/hassio/switch.py
Normal file
90
homeassistant/components/hassio/switch.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Switch platform for Hass.io addons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ICON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
|
||||
from .entity import HassioAddonEntity
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ENTITY_DESCRIPTION = SwitchEntityDescription(
|
||||
key=ATTR_STATE,
|
||||
name=None,
|
||||
icon="mdi:puzzle",
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Switch set up for Hass.io config entry."""
|
||||
coordinator = hass.data[ADDONS_COORDINATOR]
|
||||
|
||||
async_add_entities(
|
||||
HassioAddonSwitch(
|
||||
addon=addon,
|
||||
coordinator=coordinator,
|
||||
entity_description=ENTITY_DESCRIPTION,
|
||||
)
|
||||
for addon in coordinator.data[DATA_KEY_ADDONS].values()
|
||||
)
|
||||
|
||||
|
||||
class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
|
||||
"""Switch for Hass.io add-ons."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the add-on is on."""
|
||||
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
|
||||
state = addon_data.get(self.entity_description.key)
|
||||
return state == ATTR_STARTED
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the add-on if any."""
|
||||
if not self.available:
|
||||
return None
|
||||
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
|
||||
if addon_data.get(ATTR_ICON):
|
||||
return f"/api/hassio/addons/{self._addon_slug}/icon"
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
supervisor_client = get_supervisor_client(self.hass)
|
||||
try:
|
||||
await supervisor_client.addons.start_addon(self._addon_slug)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err)
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
supervisor_client = get_supervisor_client(self.hass)
|
||||
try:
|
||||
await supervisor_client.addons.stop_addon(self._addon_slug)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Failed to stop addon %s: %s", self._addon_slug, err)
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
|
@@ -6,9 +6,14 @@ import logging
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
|
||||
from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC
|
||||
from .const import CONF_TRAFFIC_MODE, DOMAIN, TRAVEL_MODE_PUBLIC
|
||||
from .coordinator import (
|
||||
HereConfigEntry,
|
||||
HERERoutingDataUpdateCoordinator,
|
||||
@@ -24,6 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
|
||||
"""Set up HERE Travel Time from a config entry."""
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
|
||||
alert_for_multiple_entries(hass)
|
||||
|
||||
cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator]
|
||||
if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}:
|
||||
cls = HERETransitDataUpdateCoordinator
|
||||
@@ -42,6 +49,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
|
||||
return True
|
||||
|
||||
|
||||
def alert_for_multiple_entries(hass: HomeAssistant) -> None:
|
||||
"""Check if there are multiple entries for the same API key."""
|
||||
if len(hass.config_entries.async_entries(DOMAIN)) > 1:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"multiple_here_travel_time_entries",
|
||||
learn_more_url="https://www.home-assistant.io/integrations/here_travel_time/",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="multiple_here_travel_time_entries",
|
||||
translation_placeholders={
|
||||
"pricing_page": "https://www.here.com/get-started/pricing",
|
||||
},
|
||||
)
|
||||
else:
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"multiple_here_travel_time_entries",
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: HereConfigEntry
|
||||
) -> bool:
|
||||
|
@@ -44,7 +44,7 @@ from .coordinator import (
|
||||
HERETransitDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
SCAN_INTERVAL = timedelta(minutes=30)
|
||||
|
||||
|
||||
def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]:
|
||||
|
@@ -107,5 +107,11 @@
|
||||
"name": "Destination"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"multiple_here_travel_time_entries": {
|
||||
"title": "More than one HERE Travel Time integration detected",
|
||||
"description": "HERE deprecated the previous free tier. The new Base Plan has only 5000 instead of the previous 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -90,7 +90,7 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="OpenThread",
|
||||
expected_installed_firmware_type=ApplicationType.SPINEL,
|
||||
step_id="install_thread_firmware",
|
||||
next_step_id="start_otbr_addon",
|
||||
next_step_id="finish_thread_installation",
|
||||
)
|
||||
|
||||
|
||||
|
@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
@@ -72,8 +72,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
_failed_addon_name: str
|
||||
_failed_addon_reason: str
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
@@ -85,10 +83,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self._hardware_name: str = "unknown" # To be set in a subclass
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
|
||||
self.addon_install_task: asyncio.Task | None = None
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
self.addon_uninstall_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task[None] | None = None
|
||||
self.installing_firmware_name: str | None = None
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
@@ -127,8 +123,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread or Zigbee firmware."""
|
||||
# Determine if ZHA or Thread are already configured to present migrate options
|
||||
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
|
||||
otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN)
|
||||
zha_entries = self.hass.config_entries.async_entries(
|
||||
ZHA_DOMAIN, include_ignore=False
|
||||
)
|
||||
otbr_entries = self.hass.config_entries.async_entries(
|
||||
OTBR_DOMAIN, include_ignore=False
|
||||
)
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="pick_firmware",
|
||||
@@ -184,91 +184,17 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
) -> ConfigFlowResult:
|
||||
assert self._device is not None
|
||||
|
||||
"""Show progress dialog for installing firmware."""
|
||||
if not self.firmware_install_task:
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to index download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError):
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to image download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
# Otherwise, fail
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
self._install_firmware(
|
||||
fw_update_url,
|
||||
fw_type,
|
||||
firmware_name,
|
||||
expected_installed_firmware_type,
|
||||
),
|
||||
f"Flash {firmware_name} firmware",
|
||||
f"Install {firmware_name} firmware",
|
||||
)
|
||||
|
||||
if not self.firmware_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
@@ -282,12 +208,102 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
try:
|
||||
await self.firmware_install_task
|
||||
except AbortFlow as err:
|
||||
return self.async_show_progress_done(
|
||||
next_step_id=err.reason,
|
||||
)
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Failed to flash firmware")
|
||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
||||
finally:
|
||||
self.firmware_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def _install_firmware(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
fw_type: str,
|
||||
firmware_name: str,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
) -> None:
|
||||
"""Install firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
raise AbortFlow(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
assert self._device is not None
|
||||
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||
return
|
||||
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to image download failure")
|
||||
return
|
||||
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
)
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
@@ -353,6 +369,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_unsupported_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when unsupported firmware is detected."""
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def async_step_zigbee_installation_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -406,20 +431,42 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
|
||||
"""Continue to the picked firmware step."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
return await self.async_step_prepare_thread_installation()
|
||||
|
||||
async def async_step_prepare_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Prepare for Thread installation by stopping the OTBR addon if needed."""
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# Stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_finish_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish Thread installation by starting the OTBR addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
return await self.async_step_start_otbr_addon()
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -439,18 +486,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Install Zigbee firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_step_addon_operation_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when add-on installation or start failed."""
|
||||
return self.async_abort(
|
||||
reason=self._failed_addon_reason,
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": self._failed_addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pre_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -495,28 +530,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Continue the ZHA flow."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
|
||||
"""Ensure the OTBR addon is set up and not running."""
|
||||
|
||||
# We install the OTBR addon no matter what, since it is required to use Thread
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# Stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_pick_firmware_thread(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -536,6 +549,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Install Thread firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_install_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -545,70 +564,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
_LOGGER.debug("OTBR addon info: %s", addon_info)
|
||||
|
||||
if not self.addon_install_task:
|
||||
self.addon_install_task = self.hass.async_create_task(
|
||||
addon_manager.async_install_addon_waiting(),
|
||||
"OTBR addon install",
|
||||
)
|
||||
|
||||
if not self.addon_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="install_otbr_addon",
|
||||
progress_action="install_addon",
|
||||
try:
|
||||
await addon_manager.async_install_addon_waiting()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_install_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": addon_manager.addon_name,
|
||||
},
|
||||
progress_task=self.addon_install_task,
|
||||
)
|
||||
) from err
|
||||
|
||||
try:
|
||||
await self.addon_install_task
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = addon_manager.addon_name
|
||||
self._failed_addon_reason = "addon_install_failed"
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="install_thread_firmware")
|
||||
return await self.async_step_finish_thread_installation()
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_start_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
|
||||
if not self.addon_start_task:
|
||||
self.addon_start_task = self.hass.async_create_task(
|
||||
self._configure_and_start_otbr_addon()
|
||||
)
|
||||
|
||||
if not self.addon_start_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="start_otbr_addon",
|
||||
progress_action="start_otbr_addon",
|
||||
try:
|
||||
await self._configure_and_start_otbr_addon()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_start_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
},
|
||||
progress_task=self.addon_start_task,
|
||||
)
|
||||
) from err
|
||||
|
||||
try:
|
||||
await self.addon_start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = otbr_manager.addon_name
|
||||
self._failed_addon_reason = (
|
||||
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_start_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||
return await self.async_step_pre_confirm_otbr()
|
||||
|
||||
async def async_step_pre_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -616,20 +608,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Pre-confirm OTBR setup."""
|
||||
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_confirm_otbr()
|
||||
|
||||
async def async_step_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm OTBR setup."""
|
||||
assert self._device is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="confirm_otbr",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
# OTBR discovery is done automatically via hassio
|
||||
return self._async_flow_finished()
|
||||
|
||||
|
@@ -106,7 +106,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="OpenThread",
|
||||
expected_installed_firmware_type=ApplicationType.SPINEL,
|
||||
step_id="install_thread_firmware",
|
||||
next_step_id="start_otbr_addon",
|
||||
next_step_id="finish_thread_installation",
|
||||
)
|
||||
|
||||
|
||||
|
@@ -105,7 +105,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="OpenThread",
|
||||
expected_installed_firmware_type=ApplicationType.SPINEL,
|
||||
step_id="install_thread_firmware",
|
||||
next_step_id="start_otbr_addon",
|
||||
next_step_id="finish_thread_installation",
|
||||
)
|
||||
|
||||
|
||||
|
@@ -63,7 +63,7 @@ async def async_get_controller(hass: HomeAssistant) -> Controller:
|
||||
|
||||
controller = Controller(
|
||||
async_zeroconf_instance=async_zeroconf_instance,
|
||||
bleak_scanner_instance=bleak_scanner_instance, # type: ignore[arg-type]
|
||||
bleak_scanner_instance=bleak_scanner_instance,
|
||||
char_cache=char_cache,
|
||||
)
|
||||
|
||||
|
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.models.bell_button import BellButton
|
||||
from aiohue.v2.models.button import Button
|
||||
from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection
|
||||
|
||||
@@ -39,19 +40,27 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_add_entity(
|
||||
event_type: EventType,
|
||||
resource: Button | RelativeRotary,
|
||||
resource: Button | RelativeRotary | BellButton,
|
||||
) -> None:
|
||||
"""Add entity from Hue resource."""
|
||||
if isinstance(resource, RelativeRotary):
|
||||
async_add_entities(
|
||||
[HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)]
|
||||
)
|
||||
elif isinstance(resource, BellButton):
|
||||
async_add_entities(
|
||||
[HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)]
|
||||
)
|
||||
else:
|
||||
async_add_entities(
|
||||
[HueButtonEventEntity(bridge, api.sensors.button, resource)]
|
||||
)
|
||||
|
||||
for controller in (api.sensors.button, api.sensors.relative_rotary):
|
||||
for controller in (
|
||||
api.sensors.button,
|
||||
api.sensors.relative_rotary,
|
||||
api.sensors.bell_button,
|
||||
):
|
||||
# add all current items in controller
|
||||
for item in controller:
|
||||
async_add_entity(EventType.RESOURCE_ADDED, item)
|
||||
@@ -67,6 +76,8 @@ async def async_setup_entry(
|
||||
class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
"""Representation of a Hue Event entity from a button resource."""
|
||||
|
||||
resource: Button | BellButton
|
||||
|
||||
entity_description = EventEntityDescription(
|
||||
key="button",
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
@@ -91,7 +102,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_event(self, event_type: EventType, resource: Button) -> None:
|
||||
def _handle_event(
|
||||
self, event_type: EventType, resource: Button | BellButton
|
||||
) -> None:
|
||||
"""Handle status event for this resource (or it's parent)."""
|
||||
if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id:
|
||||
if resource.button is None or resource.button.button_report is None:
|
||||
@@ -102,6 +115,18 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
super()._handle_event(event_type, resource)
|
||||
|
||||
|
||||
class HueBellButtonEventEntity(HueButtonEventEntity):
|
||||
"""Representation of a Hue Event entity from a bell_button resource."""
|
||||
|
||||
resource: Button | BellButton
|
||||
|
||||
entity_description = EventEntityDescription(
|
||||
key="bell_button",
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
has_entity_name=True,
|
||||
)
|
||||
|
||||
|
||||
class HueRotaryEventEntity(HueBaseEntity, EventEntity):
|
||||
"""Representation of a Hue Event entity from a RelativeRotary resource."""
|
||||
|
||||
|
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.7.5"],
|
||||
"requirements": ["aiohue==4.8.0"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
@@ -13,13 +13,18 @@ from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.sensors import (
|
||||
CameraMotionController,
|
||||
ContactController,
|
||||
GroupedMotionController,
|
||||
MotionController,
|
||||
SecurityAreaMotionController,
|
||||
TamperController,
|
||||
)
|
||||
from aiohue.v2.models.camera_motion import CameraMotion
|
||||
from aiohue.v2.models.contact import Contact, ContactState
|
||||
from aiohue.v2.models.entertainment_configuration import EntertainmentStatus
|
||||
from aiohue.v2.models.grouped_motion import GroupedMotion
|
||||
from aiohue.v2.models.motion import Motion
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.security_area_motion import SecurityAreaMotion
|
||||
from aiohue.v2.models.tamper import Tamper, TamperState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -29,21 +34,54 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from ..bridge import HueConfigEntry
|
||||
from ..bridge import HueBridge, HueConfigEntry
|
||||
from ..const import DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
|
||||
type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper
|
||||
type SensorType = (
|
||||
CameraMotion
|
||||
| Contact
|
||||
| Motion
|
||||
| EntertainmentConfiguration
|
||||
| Tamper
|
||||
| GroupedMotion
|
||||
| SecurityAreaMotion
|
||||
)
|
||||
type ControllerType = (
|
||||
CameraMotionController
|
||||
| ContactController
|
||||
| MotionController
|
||||
| EntertainmentConfigurationController
|
||||
| TamperController
|
||||
| GroupedMotionController
|
||||
| SecurityAreaMotionController
|
||||
)
|
||||
|
||||
|
||||
def _resource_valid(resource: SensorType, controller: ControllerType) -> bool:
|
||||
"""Return True if the resource is valid."""
|
||||
if isinstance(resource, GroupedMotion):
|
||||
# filter out GroupedMotion sensors that are not linked to a valid group/parent
|
||||
if resource.owner.rtype not in (
|
||||
ResourceTypes.ROOM,
|
||||
ResourceTypes.ZONE,
|
||||
ResourceTypes.SERVICE_GROUP,
|
||||
):
|
||||
return False
|
||||
# guard against GroupedMotion without parent (should not happen, but just in case)
|
||||
if not (parent := controller.get_parent(resource.id)):
|
||||
return False
|
||||
# filter out GroupedMotion sensors that have only one member, because Hue creates one
|
||||
# default grouped Motion sensor per zone/room, which is not useful to expose in HA
|
||||
if len(parent.children) <= 1:
|
||||
return False
|
||||
# default/other checks can go here (none for now)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HueConfigEntry,
|
||||
@@ -59,11 +97,17 @@ async def async_setup_entry(
|
||||
|
||||
@callback
|
||||
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
|
||||
"""Add Hue Binary Sensor."""
|
||||
"""Add Hue Binary Sensor from resource added callback."""
|
||||
if not _resource_valid(resource, controller):
|
||||
return
|
||||
async_add_entities([make_binary_sensor_entity(resource)])
|
||||
|
||||
# add all current items in controller
|
||||
async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller)
|
||||
async_add_entities(
|
||||
make_binary_sensor_entity(sensor)
|
||||
for sensor in controller
|
||||
if _resource_valid(sensor, controller)
|
||||
)
|
||||
|
||||
# register listener for new sensors
|
||||
config_entry.async_on_unload(
|
||||
@@ -78,6 +122,8 @@ async def async_setup_entry(
|
||||
register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor)
|
||||
register_items(api.sensors.contact, HueContactSensor)
|
||||
register_items(api.sensors.tamper, HueTamperSensor)
|
||||
register_items(api.sensors.grouped_motion, HueGroupedMotionSensor)
|
||||
register_items(api.sensors.security_area_motion, HueMotionAwareSensor)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
@@ -102,6 +148,83 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity):
|
||||
return self.resource.motion.value
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueGroupedMotionSensor(HueMotionSensor):
|
||||
"""Representation of a Hue Grouped Motion sensor."""
|
||||
|
||||
controller: GroupedMotionController
|
||||
resource: GroupedMotion
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: GroupedMotionController,
|
||||
resource: GroupedMotion,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the GroupedMotion sensor to the parent the sensor is associated with
|
||||
# which can either be a special ServiceGroup or a Zone/Room
|
||||
parent = self.controller.get_parent(resource.id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, parent.id)},
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueMotionAwareSensor(HueMotionSensor):
|
||||
"""Representation of a Motion sensor based on Hue Motion Aware.
|
||||
|
||||
Note that we only create sensors for the SecurityAreaMotion resource
|
||||
and not for the ConvenienceAreaMotion resource, because the latter
|
||||
does not have a state when it's not directly controlling lights.
|
||||
The SecurityAreaMotion resource is always available with a state, allowing
|
||||
Home Assistant users to actually use it as a motion sensor in their HA automations.
|
||||
"""
|
||||
|
||||
controller: SecurityAreaMotionController
|
||||
resource: SecurityAreaMotion
|
||||
|
||||
entity_description = BinarySensorEntityDescription(
|
||||
key="motion_sensor",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
has_entity_name=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return sensor name."""
|
||||
return self.controller.get_motion_area_configuration(self.resource.id).name
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: SecurityAreaMotionController,
|
||||
resource: SecurityAreaMotion,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the MotionAware sensor to the group the sensor is associated with
|
||||
self._motion_area_configuration = self.controller.get_motion_area_configuration(
|
||||
resource.id
|
||||
)
|
||||
group_id = self._motion_area_configuration.group.rid
|
||||
self.group = self.bridge.api.groups[group_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.group.id)},
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
# subscribe to updates of the MotionAreaConfiguration to update the name
|
||||
self.async_on_remove(
|
||||
self.bridge.api.config.subscribe(
|
||||
self._handle_event, self._motion_area_configuration.id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a Hue Entertainment Configuration as binary sensor."""
|
||||
|
@@ -9,6 +9,7 @@ from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.groups import Room, Zone
|
||||
from aiohue.v2.models.device import Device
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.service_group import ServiceGroup
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
@@ -39,16 +40,16 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
dev_controller = api.devices
|
||||
|
||||
@callback
|
||||
def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry:
|
||||
def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry:
|
||||
"""Register a Hue device in device registry."""
|
||||
if isinstance(hue_resource, (Room, Zone)):
|
||||
if isinstance(hue_resource, (Room, Zone, ServiceGroup)):
|
||||
# Register a Hue Room/Zone as service in HA device registry.
|
||||
return dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, hue_resource.id)},
|
||||
name=hue_resource.metadata.name,
|
||||
model=hue_resource.type.value.title(),
|
||||
model=hue_resource.type.value.replace("_", " ").title(),
|
||||
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
|
||||
via_device=(DOMAIN, api.config.bridge_device.id),
|
||||
suggested_area=hue_resource.metadata.name
|
||||
@@ -85,7 +86,7 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
|
||||
@callback
|
||||
def handle_device_event(
|
||||
evt_type: EventType, hue_resource: Device | Room | Zone
|
||||
evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup
|
||||
) -> None:
|
||||
"""Handle event from Hue controller."""
|
||||
if evt_type == EventType.RESOURCE_DELETED:
|
||||
@@ -101,6 +102,7 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
known_devices = [add_device(hue_device) for hue_device in hue_devices]
|
||||
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
|
||||
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
|
||||
known_devices += [add_device(sg) for sg in api.config.service_group]
|
||||
|
||||
# Check for nodes that no longer exist and remove them
|
||||
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
|
||||
@@ -111,3 +113,4 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
entry.async_on_unload(dev_controller.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.groups.room.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.groups.zone.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.config.service_group.subscribe(handle_device_event))
|
||||
|
@@ -162,7 +162,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
"""Turn the grouped_light on."""
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
color_temp = normalize_hue_colortemp(
|
||||
kwargs.get(ATTR_COLOR_TEMP_KELVIN),
|
||||
color_util.color_temperature_kelvin_to_mired(self.max_color_temp_kelvin),
|
||||
color_util.color_temperature_kelvin_to_mired(self.min_color_temp_kelvin),
|
||||
)
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
|
@@ -23,11 +23,12 @@ def normalize_hue_transition(transition: float | None) -> float | None:
|
||||
return transition
|
||||
|
||||
|
||||
def normalize_hue_colortemp(colortemp_k: int | None) -> int | None:
|
||||
def normalize_hue_colortemp(
|
||||
colortemp_k: int | None, min_mireds: int, max_mireds: int
|
||||
) -> int | None:
|
||||
"""Return color temperature within Hue's ranges."""
|
||||
if colortemp_k is None:
|
||||
return None
|
||||
colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k)
|
||||
# Hue only accepts a range between 153..500
|
||||
colortemp = min(colortemp, 500)
|
||||
return max(colortemp, 153)
|
||||
colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k)
|
||||
# Hue only accepts a range between min_mireds..max_mireds
|
||||
return min(max(colortemp_mireds, min_mireds), max_mireds)
|
||||
|
@@ -40,8 +40,8 @@ from .helpers import (
|
||||
normalize_hue_transition,
|
||||
)
|
||||
|
||||
FALLBACK_MIN_KELVIN = 6500
|
||||
FALLBACK_MAX_KELVIN = 2000
|
||||
FALLBACK_MIN_MIREDS = 153 # hue default for most lights
|
||||
FALLBACK_MAX_MIREDS = 500 # hue default for most lights
|
||||
FALLBACK_KELVIN = 5800 # halfway
|
||||
|
||||
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
|
||||
@@ -177,25 +177,31 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_KELVIN
|
||||
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds (so highest number) that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds (so lowest number) that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return the coldest color_temp_kelvin that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
color_temp.mirek_schema.mirek_minimum
|
||||
)
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_MAX_KELVIN
|
||||
return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds)
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the warmest color_temp_kelvin that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
color_temp.mirek_schema.mirek_maximum
|
||||
)
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_MIN_KELVIN
|
||||
return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
@@ -220,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
"""Turn the device on."""
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
color_temp = normalize_hue_colortemp(
|
||||
kwargs.get(ATTR_COLOR_TEMP_KELVIN),
|
||||
self.min_color_temp_mireds,
|
||||
self.max_color_temp_mireds,
|
||||
)
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
if self._last_brightness and brightness is None:
|
||||
# The Hue bridge sets the brightness to 1% when turning on a bulb
|
||||
|
@@ -9,13 +9,16 @@ from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.sensors import (
|
||||
DevicePowerController,
|
||||
GroupedLightLevelController,
|
||||
LightLevelController,
|
||||
SensorsController,
|
||||
TemperatureController,
|
||||
ZigbeeConnectivityController,
|
||||
)
|
||||
from aiohue.v2.models.device_power import DevicePower
|
||||
from aiohue.v2.models.grouped_light_level import GroupedLightLevel
|
||||
from aiohue.v2.models.light_level import LightLevel
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.temperature import Temperature
|
||||
from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity
|
||||
|
||||
@@ -27,20 +30,50 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from ..bridge import HueBridge, HueConfigEntry
|
||||
from ..const import DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
|
||||
type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity
|
||||
type SensorType = (
|
||||
DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel
|
||||
)
|
||||
type ControllerType = (
|
||||
DevicePowerController
|
||||
| LightLevelController
|
||||
| TemperatureController
|
||||
| ZigbeeConnectivityController
|
||||
| GroupedLightLevelController
|
||||
)
|
||||
|
||||
|
||||
def _resource_valid(
|
||||
resource: SensorType, controller: ControllerType, api: HueBridgeV2
|
||||
) -> bool:
|
||||
"""Return True if the resource is valid."""
|
||||
if isinstance(resource, GroupedLightLevel):
|
||||
# filter out GroupedLightLevel sensors that are not linked to a valid group/parent
|
||||
if resource.owner.rtype not in (
|
||||
ResourceTypes.ROOM,
|
||||
ResourceTypes.ZONE,
|
||||
ResourceTypes.SERVICE_GROUP,
|
||||
):
|
||||
return False
|
||||
# guard against GroupedLightLevel without parent (should not happen, but just in case)
|
||||
parent_id = resource.owner.rid
|
||||
parent = api.groups.get(parent_id) or api.config.get(parent_id)
|
||||
if not parent:
|
||||
return False
|
||||
# filter out GroupedLightLevel sensors that have only one member, because Hue creates one
|
||||
# default grouped LightLevel sensor per zone/room, which is not useful to expose in HA
|
||||
if len(parent.children) <= 1:
|
||||
return False
|
||||
# default/other checks can go here (none for now)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HueConfigEntry,
|
||||
@@ -58,10 +91,16 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
|
||||
"""Add Hue Sensor."""
|
||||
if not _resource_valid(resource, controller, api):
|
||||
return
|
||||
async_add_entities([make_sensor_entity(resource)])
|
||||
|
||||
# add all current items in controller
|
||||
async_add_entities(make_sensor_entity(sensor) for sensor in controller)
|
||||
async_add_entities(
|
||||
make_sensor_entity(sensor)
|
||||
for sensor in controller
|
||||
if _resource_valid(sensor, controller, api)
|
||||
)
|
||||
|
||||
# register listener for new sensors
|
||||
config_entry.async_on_unload(
|
||||
@@ -75,6 +114,7 @@ async def async_setup_entry(
|
||||
register_items(ctrl_base.light_level, HueLightLevelSensor)
|
||||
register_items(ctrl_base.device_power, HueBatterySensor)
|
||||
register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor)
|
||||
register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
@@ -140,6 +180,31 @@ class HueLightLevelSensor(HueSensorBase):
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueGroupedLightLevelSensor(HueLightLevelSensor):
|
||||
"""Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource."""
|
||||
|
||||
controller: GroupedLightLevelController
|
||||
resource: GroupedLightLevel
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: GroupedLightLevelController,
|
||||
resource: GroupedLightLevel,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the GroupedLightLevel sensor to the parent the sensor is associated with
|
||||
# which can either be a special ServiceGroup or a Zone/Room
|
||||
api = self.bridge.api
|
||||
parent_id = resource.owner.rid
|
||||
parent = api.groups.get(parent_id) or api.config.get(parent_id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, parent.id)},
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueBatterySensor(HueSensorBase):
|
||||
"""Representation of a Hue Battery sensor."""
|
||||
|
@@ -25,6 +25,7 @@ from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
|
@@ -20,6 +20,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"light_brightness": {
|
||||
"default": "mdi:brightness-5"
|
||||
},
|
||||
"plant_days": {
|
||||
"default": "mdi:calendar-blank"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"display_temperature_unit": {
|
||||
"default": "mdi:thermometer-lines"
|
||||
|
136
homeassistant/components/letpot/number.py
Normal file
136
homeassistant/components/letpot/number.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Support for LetPot number entities."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from letpot.deviceclient import LetPotDeviceClient
|
||||
from letpot.models import DeviceFeature
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import PRECISION_WHOLE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
|
||||
from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
|
||||
|
||||
# Each change pushes a 'full' device status with the change. The library will cache
|
||||
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription):
|
||||
"""Describes a LetPot number entity."""
|
||||
|
||||
max_value_fn: Callable[[LetPotDeviceCoordinator], float]
|
||||
value_fn: Callable[[LetPotDeviceCoordinator], float | None]
|
||||
set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
NUMBERS: tuple[LetPotNumberEntityDescription, ...] = (
|
||||
LetPotNumberEntityDescription(
|
||||
key="light_brightness_levels",
|
||||
translation_key="light_brightness",
|
||||
value_fn=(
|
||||
lambda coordinator: coordinator.device_client.get_light_brightness_levels(
|
||||
coordinator.device.serial_number
|
||||
).index(coordinator.data.light_brightness)
|
||||
+ 1
|
||||
if coordinator.data.light_brightness is not None
|
||||
else None
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device_client, serial, value: device_client.set_light_brightness(
|
||||
serial,
|
||||
device_client.get_light_brightness_levels(serial)[int(value) - 1],
|
||||
)
|
||||
),
|
||||
supported_fn=(
|
||||
lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
|
||||
in coordinator.device_client.device_info(
|
||||
coordinator.device.serial_number
|
||||
).features
|
||||
),
|
||||
native_min_value=float(1),
|
||||
max_value_fn=lambda coordinator: float(
|
||||
len(
|
||||
coordinator.device_client.get_light_brightness_levels(
|
||||
coordinator.device.serial_number
|
||||
)
|
||||
)
|
||||
),
|
||||
native_step=PRECISION_WHOLE,
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
LetPotNumberEntityDescription(
|
||||
key="plant_days",
|
||||
translation_key="plant_days",
|
||||
value_fn=lambda coordinator: coordinator.data.plant_days,
|
||||
set_value_fn=(
|
||||
lambda device_client, serial, value: device_client.set_plant_days(
|
||||
serial, int(value)
|
||||
)
|
||||
),
|
||||
native_min_value=float(0),
|
||||
max_value_fn=lambda _: float(999),
|
||||
native_step=PRECISION_WHOLE,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LetPotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LetPot number entities based on a config entry and device status/features."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
LetPotNumberEntity(coordinator, description)
|
||||
for description in NUMBERS
|
||||
for coordinator in coordinators
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
|
||||
class LetPotNumberEntity(LetPotEntity, NumberEntity):
|
||||
"""Defines a LetPot number entity."""
|
||||
|
||||
entity_description: LetPotNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LetPotDeviceCoordinator,
|
||||
description: LetPotNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize LetPot number entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum available value."""
|
||||
return self.entity_description.max_value_fn(self.coordinator)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the number value."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Change the number value."""
|
||||
return await self.entity_description.set_value_fn(
|
||||
self.coordinator.device_client,
|
||||
self.coordinator.device.serial_number,
|
||||
value,
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user