mirror of
https://github.com/home-assistant/core.git
synced 2026-05-04 03:51:12 +02:00
Merge branch 'dev' into llm_device_name
This commit is contained in:
Generated
+4
-2
@@ -410,6 +410,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
|
||||
@@ -772,6 +774,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/iqvia/ @bachya
|
||||
/tests/components/iqvia/ @bachya
|
||||
/homeassistant/components/irish_rail_transport/ @ttroy50
|
||||
/homeassistant/components/irm_kmi/ @jdejaegh
|
||||
/tests/components/irm_kmi/ @jdejaegh
|
||||
/homeassistant/components/iron_os/ @tr4nt0r
|
||||
/tests/components/iron_os/ @tr4nt0r
|
||||
/homeassistant/components/isal/ @bdraco
|
||||
@@ -970,8 +974,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
|
||||
|
||||
@@ -4,10 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aioacaia.acaiascale import AcaiaScale
|
||||
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
|
||||
from bleak import BleakScanner
|
||||
|
||||
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 +45,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=cast(BleakScanner, 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"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
|
||||
@@ -22,6 +23,8 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for AccuWeather."""
|
||||
|
||||
VERSION = 1
|
||||
_latitude: float | None = None
|
||||
_longitude: float | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -74,3 +77,46 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._latitude = entry_data[CONF_LATITUDE]
|
||||
self._longitude = entry_data[CONF_LONGITUDE]
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with timeout(10):
|
||||
accuweather = AccuWeather(
|
||||
user_input[CONF_API_KEY],
|
||||
websession,
|
||||
latitude=self._latitude,
|
||||
longitude=self._longitude,
|
||||
)
|
||||
await accuweather.async_get_location()
|
||||
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except RequestsExceededError:
|
||||
errors["base"] = "requests_exceeded"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from aiohttp.client_exceptions import ClientConnectorError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
@@ -30,7 +31,7 @@ from .const import (
|
||||
UPDATE_INTERVAL_OBSERVATION,
|
||||
)
|
||||
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +53,8 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
):
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -87,6 +90,12 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
translation_key="current_conditions_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
|
||||
@@ -98,6 +107,8 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
):
|
||||
"""Base class for AccuWeather forecast."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -137,6 +148,12 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
translation_key="forecast_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
return result
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key generated in the AccuWeather APIs portal."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19,7 +30,8 @@
|
||||
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -239,6 +251,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed for {entry}, please update your API key"
|
||||
},
|
||||
"current_conditions_update_error": {
|
||||
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
|
||||
return []
|
||||
|
||||
call_ids = await async_extract_entity_ids(hass, call)
|
||||
call_ids = await async_extract_entity_ids(call)
|
||||
entity_ids = []
|
||||
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
|
||||
if entity_id not in call_ids:
|
||||
|
||||
@@ -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, UndefinedType
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.setup import async_get_loaded_integrations
|
||||
@@ -75,12 +79,116 @@ 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
|
||||
capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED
|
||||
|
||||
|
||||
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 +501,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 +527,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 +642,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 +658,53 @@ 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,
|
||||
"capabilities": entity_config.capabilities
|
||||
if entity_config.capabilities is not UNDEFINED
|
||||
else entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"modified_by_integration": ["capabilities"]
|
||||
if entity_config.capabilities is not UNDEFINED
|
||||
else None,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
@@ -153,8 +143,8 @@ class IntentCacheKey:
|
||||
language: str
|
||||
"""Language of text."""
|
||||
|
||||
device_id: str | None
|
||||
"""Device id from user input."""
|
||||
satellite_id: str | None
|
||||
"""Satellite id from user input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -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)
|
||||
|
||||
@@ -443,9 +435,15 @@ class DefaultAgent(ConversationEntity):
|
||||
}
|
||||
for entity in result.entities_list
|
||||
}
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area:
|
||||
slots["preferred_area_id"] = {"value": device_area.id}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
satellite_area, device_id = self._get_satellite_area_and_device(
|
||||
satellite_id, device_id
|
||||
)
|
||||
if satellite_area is not None:
|
||||
slots["preferred_area_id"] = {"value": satellite_area.id}
|
||||
|
||||
async_conversation_trace_append(
|
||||
ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
@@ -467,8 +465,8 @@ class DefaultAgent(ConversationEntity):
|
||||
user_input.context,
|
||||
language,
|
||||
assistant=DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
satellite_id=user_input.satellite_id,
|
||||
device_id=device_id,
|
||||
satellite_id=satellite_id,
|
||||
conversation_agent_id=user_input.agent_id,
|
||||
)
|
||||
except intent.MatchFailedError as match_error:
|
||||
@@ -534,7 +532,9 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Try cache first
|
||||
cache_key = IntentCacheKey(
|
||||
text=user_input.text, language=language, device_id=user_input.device_id
|
||||
text=user_input.text,
|
||||
language=language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
)
|
||||
cache_value = self._intent_cache.get(cache_key)
|
||||
if cache_value is not None:
|
||||
@@ -1190,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")
|
||||
@@ -1304,28 +1304,40 @@ class DefaultAgent(ConversationEntity):
|
||||
self, user_input: ConversationInput
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return intent recognition context for user input."""
|
||||
if not user_input.device_id:
|
||||
satellite_area, _ = self._get_satellite_area_and_device(
|
||||
user_input.satellite_id, user_input.device_id
|
||||
)
|
||||
if satellite_area is None:
|
||||
return None
|
||||
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area is None:
|
||||
return None
|
||||
return {"area": {"value": satellite_area.name, "text": satellite_area.name}}
|
||||
|
||||
return {"area": {"value": device_area.name, "text": device_area.name}}
|
||||
def _get_satellite_area_and_device(
|
||||
self, satellite_id: str | None, device_id: str | None = None
|
||||
) -> tuple[ar.AreaEntry | None, str | None]:
|
||||
"""Return area entry and device id."""
|
||||
hass = self.hass
|
||||
|
||||
def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
|
||||
"""Return area object for given device identifier."""
|
||||
if device_id is None:
|
||||
return None
|
||||
area_id: str | None = None
|
||||
|
||||
devices = dr.async_get(self.hass)
|
||||
device = devices.async_get(device_id)
|
||||
if (device is None) or (device.area_id is None):
|
||||
return None
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None
|
||||
):
|
||||
area_id = entity_entry.area_id
|
||||
device_id = entity_entry.device_id
|
||||
|
||||
areas = ar.async_get(self.hass)
|
||||
if (
|
||||
area_id is None
|
||||
and device_id is not None
|
||||
and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
|
||||
):
|
||||
area_id = device_entry.area_id
|
||||
|
||||
return areas.async_get_area(device.area_id)
|
||||
if area_id is None:
|
||||
return None, device_id
|
||||
|
||||
return ar.async_get(hass).async_get_area(area_id), device_id
|
||||
|
||||
def _get_error_text(
|
||||
self,
|
||||
@@ -1349,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 = {
|
||||
@@ -1373,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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1394,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:
|
||||
@@ -1410,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
|
||||
|
||||
@@ -1455,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})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +17,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.script import ScriptRunResult
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
@@ -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."""
|
||||
@@ -71,6 +85,8 @@ async def async_attach_trigger(
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
sentences = config.get(CONF_COMMAND, [])
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
job = HassJob(action)
|
||||
|
||||
async def call_action(
|
||||
@@ -92,6 +108,14 @@ async def async_attach_trigger(
|
||||
for entity_name, entity in result.entities.items()
|
||||
}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (satellite_entry := ent_reg.async_get(satellite_id)) is not None
|
||||
):
|
||||
device_id = satellite_entry.device_id
|
||||
|
||||
trigger_input: dict[str, Any] = { # Satisfy type checker
|
||||
**trigger_data,
|
||||
"platform": DOMAIN,
|
||||
@@ -100,8 +124,8 @@ async def async_attach_trigger(
|
||||
"slots": { # direct access to values
|
||||
entity_name: entity["value"] for entity_name, entity in details.items()
|
||||
},
|
||||
"device_id": user_input.device_id,
|
||||
"satellite_id": user_input.satellite_id,
|
||||
"device_id": device_id,
|
||||
"satellite_id": satellite_id,
|
||||
"user_input": user_input.as_dict(),
|
||||
}
|
||||
|
||||
@@ -124,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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 go to 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} plattform. 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
|
||||
client_info=CLIENT_INFO,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
timezone=hass.config.time_zone,
|
||||
)
|
||||
|
||||
domain_data = DomainData.get(hass)
|
||||
|
||||
@@ -138,6 +138,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
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 +518,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 +571,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 +614,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -505,7 +505,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)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.5.0",
|
||||
"aioesphomeapi==41.9.0",
|
||||
"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)
|
||||
|
||||
@@ -66,26 +66,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
key="last_alarm_type_name",
|
||||
translation_key="last_alarm_type_name",
|
||||
),
|
||||
"Record_Mode": SensorEntityDescription(
|
||||
key="Record_Mode",
|
||||
translation_key="record_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"battery_camera_work_mode": SensorEntityDescription(
|
||||
key="battery_camera_work_mode",
|
||||
translation_key="battery_camera_work_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"powerStatus": SensorEntityDescription(
|
||||
key="powerStatus",
|
||||
translation_key="power_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"OnlineStatus": SensorEntityDescription(
|
||||
key="OnlineStatus",
|
||||
translation_key="online_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -96,26 +76,16 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up EZVIZ sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[EzvizSensor] = []
|
||||
|
||||
for camera, sensors in coordinator.data.items():
|
||||
entities.extend(
|
||||
async_add_entities(
|
||||
[
|
||||
EzvizSensor(coordinator, camera, sensor)
|
||||
for sensor, value in sensors.items()
|
||||
if sensor in SENSOR_TYPES and value is not None
|
||||
)
|
||||
|
||||
optionals = sensors.get("optionals", {})
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, optional_key)
|
||||
for optional_key in ("powerStatus", "OnlineStatus")
|
||||
if optional_key in optionals
|
||||
)
|
||||
|
||||
if "mode" in optionals.get("Record_Mode", {}):
|
||||
entities.append(EzvizSensor(coordinator, camera, "mode"))
|
||||
|
||||
async_add_entities(entities)
|
||||
for camera in coordinator.data
|
||||
for sensor, value in coordinator.data[camera].items()
|
||||
if sensor in SENSOR_TYPES
|
||||
if value is not None
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class EzvizSensor(EzvizEntity, SensorEntity):
|
||||
|
||||
@@ -147,18 +147,6 @@
|
||||
},
|
||||
"last_alarm_type_name": {
|
||||
"name": "Last alarm type name"
|
||||
},
|
||||
"record_mode": {
|
||||
"name": "Record mode"
|
||||
},
|
||||
"battery_camera_work_mode": {
|
||||
"name": "Battery work mode"
|
||||
},
|
||||
"power_status": {
|
||||
"name": "Power status"
|
||||
},
|
||||
"online_status": {
|
||||
"name": "Online status"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"read_file": {
|
||||
"service": "mdi: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}
|
||||
@@ -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.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,12 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
|
||||
|
||||
async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
"""Call Fritz set guest wifi password service."""
|
||||
hass = service_call.hass
|
||||
target_entry_ids = await async_extract_config_entry_ids(hass, service_call)
|
||||
target_entry_ids = await async_extract_config_entry_ids(service_call)
|
||||
target_entries: list[FritzConfigEntry] = [
|
||||
loaded_entry
|
||||
for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
if loaded_entry.entry_id in target_entry_ids
|
||||
]
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def _extract_gmail_config_entries(
|
||||
) -> list[GoogleMailConfigEntry]:
|
||||
return [
|
||||
entry
|
||||
for entry_id in await async_extract_config_entry_ids(call.hass, call)
|
||||
for entry_id in await async_extract_config_entry_ids(call)
|
||||
if (entry := call.hass.config_entries.async_get_entry(entry_id))
|
||||
and entry.domain == DOMAIN
|
||||
]
|
||||
|
||||
@@ -10,9 +10,8 @@ from typing import Self, cast
|
||||
from google_photos_library_api.exceptions import GooglePhotosApiError
|
||||
from google_photos_library_api.model import Album, MediaItem
|
||||
|
||||
from homeassistant.components.media_player import MediaClass, MediaType
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseError,
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
|
||||
@@ -39,6 +39,7 @@ ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item"
|
||||
ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item"
|
||||
ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item"
|
||||
ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item"
|
||||
ATTR_COLLAPSE_CHECKLIST = "collapse_checklist"
|
||||
ATTR_REMINDER = "reminder"
|
||||
ATTR_REMOVE_REMINDER = "remove_reminder"
|
||||
ATTR_CLEAR_REMINDER = "clear_reminder"
|
||||
|
||||
@@ -47,6 +47,7 @@ from .const import (
|
||||
ATTR_ALIAS,
|
||||
ATTR_CLEAR_DATE,
|
||||
ATTR_CLEAR_REMINDER,
|
||||
ATTR_COLLAPSE_CHECKLIST,
|
||||
ATTR_CONFIG_ENTRY,
|
||||
ATTR_COST,
|
||||
ATTR_COUNTER_DOWN,
|
||||
@@ -130,6 +131,11 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
COLLAPSE_CHECKLIST_MAP = {
|
||||
"collapsed": True,
|
||||
"expanded": False,
|
||||
}
|
||||
|
||||
BASE_TASK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
@@ -160,6 +166,7 @@ BASE_TASK_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_COLLAPSE_CHECKLIST): vol.In(COLLAPSE_CHECKLIST_MAP),
|
||||
vol.Optional(ATTR_START_DATE): cv.date,
|
||||
vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)),
|
||||
vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]),
|
||||
@@ -223,6 +230,7 @@ ITEMID_MAP = {
|
||||
"shiny_seed": Skill.SHINY_SEED,
|
||||
}
|
||||
|
||||
|
||||
SERVICE_TASK_TYPE_MAP = {
|
||||
SERVICE_UPDATE_REWARD: TaskType.REWARD,
|
||||
SERVICE_CREATE_REWARD: TaskType.REWARD,
|
||||
@@ -714,6 +722,9 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
):
|
||||
data["checklist"] = checklist
|
||||
|
||||
if collapse_checklist := call.data.get(ATTR_COLLAPSE_CHECKLIST):
|
||||
data["collapseChecklist"] = COLLAPSE_CHECKLIST_MAP[collapse_checklist]
|
||||
|
||||
reminders = current_task.reminders if current_task else []
|
||||
|
||||
if add_reminders := call.data.get(ATTR_REMINDER):
|
||||
|
||||
@@ -275,6 +275,15 @@ update_todo:
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
collapse_checklist: &collapse_checklist
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- collapsed
|
||||
- expanded
|
||||
mode: list
|
||||
translation_key: collapse_checklist
|
||||
priority: *priority
|
||||
duedate_options:
|
||||
collapsed: true
|
||||
@@ -318,6 +327,7 @@ create_todo:
|
||||
name: *name
|
||||
notes: *notes
|
||||
add_checklist_item: *add_checklist_item
|
||||
collapse_checklist: *collapse_checklist
|
||||
priority: *priority
|
||||
date: *due_date
|
||||
reminder: *reminder
|
||||
@@ -419,6 +429,7 @@ create_daily:
|
||||
name: *name
|
||||
notes: *notes
|
||||
add_checklist_item: *add_checklist_item
|
||||
collapse_checklist: *collapse_checklist
|
||||
priority: *priority
|
||||
start_date: *start_date
|
||||
frequency: *frequency_daily
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
"repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.",
|
||||
"repeat_monthly_options_name": "Monthly repeat day",
|
||||
"repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.",
|
||||
"quest_name": "Quest"
|
||||
"quest_name": "Quest",
|
||||
"collapse_checklist_name": "Collapse/expand checklist",
|
||||
"collapse_checklist_description": "Whether the checklist of a task is displayed as collapsed or expanded in Habitica."
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
@@ -1006,6 +1008,10 @@
|
||||
"unscore_checklist_item": {
|
||||
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
|
||||
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
@@ -1070,6 +1076,10 @@
|
||||
"add_checklist_item": {
|
||||
"name": "[%key:component::habitica::common::checklist_options_name%]",
|
||||
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
@@ -1151,6 +1161,10 @@
|
||||
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
|
||||
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
},
|
||||
"streak": {
|
||||
"name": "Adjust streak",
|
||||
"description": "Adjust or reset the streak counter of the daily."
|
||||
@@ -1247,6 +1261,10 @@
|
||||
"name": "[%key:component::habitica::common::checklist_options_name%]",
|
||||
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "[%key:component::habitica::common::reminder_options_name%]",
|
||||
"description": "[%key:component::habitica::common::reminder_description%]"
|
||||
@@ -1325,6 +1343,12 @@
|
||||
"day_of_month": "Day of the month",
|
||||
"day_of_week": "Day of the week"
|
||||
}
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"options": {
|
||||
"collapsed": "Collapsed",
|
||||
"expanded": "Expanded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,7 +70,7 @@ PATHS_ADMIN = re.compile(
|
||||
r"|backups/new/upload"
|
||||
r"|audio/logs(/follow|/boots/-?\d+(/follow)?)?"
|
||||
r"|cli/logs(/follow|/boots/-?\d+(/follow)?)?"
|
||||
r"|core/logs(/follow|/boots/-?\d+(/follow)?)?"
|
||||
r"|core/logs(/latest|/follow|/boots/-?\d+(/follow)?)?"
|
||||
r"|dns/logs(/follow|/boots/-?\d+(/follow)?)?"
|
||||
r"|host/logs(/follow|/boots(/-?\d+(/follow)?)?)?"
|
||||
r"|multicast/logs(/follow|/boots/-?\d+(/follow)?)?"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
reload_entries: set[str] = set()
|
||||
if ATTR_ENTRY_ID in call.data:
|
||||
reload_entries.add(call.data[ATTR_ENTRY_ID])
|
||||
reload_entries.update(await async_extract_config_entry_ids(hass, call))
|
||||
reload_entries.update(await async_extract_config_entry_ids(call))
|
||||
if not reload_entries:
|
||||
raise ValueError("There were no matching config entries to reload")
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -272,7 +272,7 @@ async def async_setup_platform(
|
||||
|
||||
async def delete_service(call: ServiceCall) -> None:
|
||||
"""Delete a dynamically created scene."""
|
||||
entity_ids = await async_extract_entity_ids(hass, call)
|
||||
entity_ids = await async_extract_entity_ids(call)
|
||||
|
||||
for entity_id in entity_ids:
|
||||
scene = platform.entities.get(entity_id)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
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]:
|
||||
@@ -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:
|
||||
@@ -495,28 +542,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:
|
||||
@@ -572,7 +597,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
finally:
|
||||
self.addon_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="install_thread_firmware")
|
||||
return self.async_show_progress_done(next_step_id="finish_thread_installation")
|
||||
|
||||
async def async_step_start_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -616,20 +641,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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -9,9 +9,8 @@ from aioimmich.assets.models import ImmichAsset
|
||||
from aioimmich.exceptions import ImmichError
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import MediaClass
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseError,
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import (
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
entities: dict[str, EntityAnalyticsModifications] = {}
|
||||
for entity_id in analytics_input.entity_ids:
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
if entity_entry.capabilities is not None:
|
||||
capabilities = dict(entity_entry.capabilities)
|
||||
capabilities["options"] = len(capabilities["options"])
|
||||
entities[entity_id] = EntityAnalyticsModifications(
|
||||
capabilities=capabilities
|
||||
)
|
||||
|
||||
return AnalyticsModifications(entities=entities)
|
||||
@@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry,
|
||||
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
|
||||
)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_handle_source_entity_changes(
|
||||
@@ -51,7 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
|
||||
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
|
||||
return True
|
||||
|
||||
|
||||
@@ -89,13 +89,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
return True
|
||||
|
||||
|
||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update listener, called when the config entry options are changed."""
|
||||
# Remove device link for entry, the source device may have changed.
|
||||
# The link will be recreated after load.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
|
||||
|
||||
@@ -151,6 +151,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
options_flow_reloads = True
|
||||
|
||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||
"""Return config entry title."""
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Integration for IRM KMI weather."""
|
||||
|
||||
import logging
|
||||
|
||||
from irm_kmi_api import IrmKmiApiClientHa
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import IRM_KMI_TO_HA_CONDITION_MAP, PLATFORMS, USER_AGENT
|
||||
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
api_client = IrmKmiApiClientHa(
|
||||
session=async_get_clientsession(hass),
|
||||
user_agent=USER_AGENT,
|
||||
cdt_map=IRM_KMI_TO_HA_CONDITION_MAP,
|
||||
)
|
||||
|
||||
entry.runtime_data = IrmKmiCoordinator(hass, entry, api_client)
|
||||
|
||||
await entry.runtime_data.async_config_entry_first_refresh()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Config flow to set up IRM KMI integration via the UI."""
|
||||
|
||||
import logging
|
||||
|
||||
from irm_kmi_api import IrmKmiApiClient, IrmKmiApiError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_UNIQUE_ID,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
LocationSelector,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_LANGUAGE_OVERRIDE,
|
||||
CONF_LANGUAGE_OVERRIDE_OPTIONS,
|
||||
DOMAIN,
|
||||
OUT_OF_BENELUX,
|
||||
USER_AGENT,
|
||||
)
|
||||
from .coordinator import IrmKmiConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Configuration flow for the IRM KMI integration."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(_config_entry: IrmKmiConfigEntry) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return IrmKmiOptionFlow()
|
||||
|
||||
async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
|
||||
"""Define the user step of the configuration flow."""
|
||||
errors: dict = {}
|
||||
|
||||
default_location = {
|
||||
ATTR_LATITUDE: self.hass.config.latitude,
|
||||
ATTR_LONGITUDE: self.hass.config.longitude,
|
||||
}
|
||||
|
||||
if user_input:
|
||||
_LOGGER.debug("Provided config user is: %s", user_input)
|
||||
|
||||
lat: float = user_input[CONF_LOCATION][ATTR_LATITUDE]
|
||||
lon: float = user_input[CONF_LOCATION][ATTR_LONGITUDE]
|
||||
|
||||
try:
|
||||
api_data = await IrmKmiApiClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
user_agent=USER_AGENT,
|
||||
).get_forecasts_coord({"lat": lat, "long": lon})
|
||||
except IrmKmiApiError:
|
||||
_LOGGER.exception(
|
||||
"Encountered an unexpected error while configuring the integration"
|
||||
)
|
||||
return self.async_abort(reason="api_error")
|
||||
|
||||
if api_data["cityName"] in OUT_OF_BENELUX:
|
||||
errors[CONF_LOCATION] = "out_of_benelux"
|
||||
|
||||
if not errors:
|
||||
name: str = api_data["cityName"]
|
||||
country: str = api_data["country"]
|
||||
unique_id: str = f"{name.lower()} {country.lower()}"
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_UNIQUE_ID] = unique_id
|
||||
|
||||
return self.async_create_entry(title=name, data=user_input)
|
||||
|
||||
default_location = user_input[CONF_LOCATION]
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_LOCATION, default=default_location
|
||||
): LocationSelector()
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class IrmKmiOptionFlow(OptionsFlowWithReload):
|
||||
"""Option flow for the IRM KMI integration, help change the options once the integration was configured."""
|
||||
|
||||
async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
_LOGGER.debug("Provided config user is: %s", user_input)
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_LANGUAGE_OVERRIDE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_LANGUAGE_OVERRIDE, "none"
|
||||
),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_LANGUAGE_OVERRIDE,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Constants for the IRM KMI integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_FOG,
|
||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
ATTR_CONDITION_RAINY,
|
||||
ATTR_CONDITION_SNOWY,
|
||||
ATTR_CONDITION_SNOWY_RAINY,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
)
|
||||
from homeassistant.const import Platform, __version__
|
||||
|
||||
DOMAIN: Final = "irm_kmi"
|
||||
PLATFORMS: Final = [Platform.WEATHER]
|
||||
|
||||
OUT_OF_BENELUX: Final = [
|
||||
"außerhalb der Benelux (Brussels)",
|
||||
"Hors de Belgique (Bxl)",
|
||||
"Outside the Benelux (Brussels)",
|
||||
"Buiten de Benelux (Brussel)",
|
||||
]
|
||||
LANGS: Final = ["en", "fr", "nl", "de"]
|
||||
|
||||
CONF_LANGUAGE_OVERRIDE: Final = "language_override"
|
||||
CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = ["none", "fr", "nl", "de", "en"]
|
||||
|
||||
# Dict to map ('ww', 'dayNight') tuple from IRM KMI to HA conditions.
|
||||
IRM_KMI_TO_HA_CONDITION_MAP: Final = {
|
||||
(0, "d"): ATTR_CONDITION_SUNNY,
|
||||
(0, "n"): ATTR_CONDITION_CLEAR_NIGHT,
|
||||
(1, "d"): ATTR_CONDITION_SUNNY,
|
||||
(1, "n"): ATTR_CONDITION_CLEAR_NIGHT,
|
||||
(2, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(2, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(3, "d"): ATTR_CONDITION_PARTLYCLOUDY,
|
||||
(3, "n"): ATTR_CONDITION_PARTLYCLOUDY,
|
||||
(4, "d"): ATTR_CONDITION_POURING,
|
||||
(4, "n"): ATTR_CONDITION_POURING,
|
||||
(5, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(5, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(6, "d"): ATTR_CONDITION_POURING,
|
||||
(6, "n"): ATTR_CONDITION_POURING,
|
||||
(7, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(7, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(8, "d"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(8, "n"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(9, "d"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(9, "n"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(10, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(10, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(11, "d"): ATTR_CONDITION_SNOWY,
|
||||
(11, "n"): ATTR_CONDITION_SNOWY,
|
||||
(12, "d"): ATTR_CONDITION_SNOWY,
|
||||
(12, "n"): ATTR_CONDITION_SNOWY,
|
||||
(13, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(13, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(14, "d"): ATTR_CONDITION_CLOUDY,
|
||||
(14, "n"): ATTR_CONDITION_CLOUDY,
|
||||
(15, "d"): ATTR_CONDITION_CLOUDY,
|
||||
(15, "n"): ATTR_CONDITION_CLOUDY,
|
||||
(16, "d"): ATTR_CONDITION_POURING,
|
||||
(16, "n"): ATTR_CONDITION_POURING,
|
||||
(17, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(17, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
(18, "d"): ATTR_CONDITION_RAINY,
|
||||
(18, "n"): ATTR_CONDITION_RAINY,
|
||||
(19, "d"): ATTR_CONDITION_POURING,
|
||||
(19, "n"): ATTR_CONDITION_POURING,
|
||||
(20, "d"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(20, "n"): ATTR_CONDITION_SNOWY_RAINY,
|
||||
(21, "d"): ATTR_CONDITION_RAINY,
|
||||
(21, "n"): ATTR_CONDITION_RAINY,
|
||||
(22, "d"): ATTR_CONDITION_SNOWY,
|
||||
(22, "n"): ATTR_CONDITION_SNOWY,
|
||||
(23, "d"): ATTR_CONDITION_SNOWY,
|
||||
(23, "n"): ATTR_CONDITION_SNOWY,
|
||||
(24, "d"): ATTR_CONDITION_FOG,
|
||||
(24, "n"): ATTR_CONDITION_FOG,
|
||||
(25, "d"): ATTR_CONDITION_FOG,
|
||||
(25, "n"): ATTR_CONDITION_FOG,
|
||||
(26, "d"): ATTR_CONDITION_FOG,
|
||||
(26, "n"): ATTR_CONDITION_FOG,
|
||||
(27, "d"): ATTR_CONDITION_FOG,
|
||||
(27, "n"): ATTR_CONDITION_FOG,
|
||||
}
|
||||
|
||||
IRM_KMI_NAME: Final = {
|
||||
"fr": "Institut Royal Météorologique de Belgique",
|
||||
"nl": "Koninklijk Meteorologisch Instituut van België",
|
||||
"de": "Königliche Meteorologische Institut von Belgien",
|
||||
"en": "Royal Meteorological Institute of Belgium",
|
||||
}
|
||||
|
||||
USER_AGENT: Final = (
|
||||
f"https://www.home-assistant.io/integrations/irm_kmi (version {__version__})"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_LOCATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
TimestampDataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .data import ProcessedCoordinatorData
|
||||
from .utils import preferred_language
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator]
|
||||
|
||||
|
||||
class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]):
|
||||
"""Coordinator to update data from IRM KMI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: IrmKmiConfigEntry,
|
||||
api_client: IrmKmiApiClientHa,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="IRM KMI weather",
|
||||
update_interval=timedelta(minutes=7),
|
||||
)
|
||||
self._api = api_client
|
||||
self._location = entry.data[CONF_LOCATION]
|
||||
|
||||
async def _async_update_data(self) -> ProcessedCoordinatorData:
|
||||
"""Fetch data from API endpoint.
|
||||
|
||||
This is the place to pre-process the data to lookup tables so entities can quickly look up their data.
|
||||
:return: ProcessedCoordinatorData
|
||||
"""
|
||||
|
||||
self._api.expire_cache()
|
||||
|
||||
try:
|
||||
await self._api.refresh_forecasts_coord(
|
||||
{
|
||||
"lat": self._location[ATTR_LATITUDE],
|
||||
"long": self._location[ATTR_LONGITUDE],
|
||||
}
|
||||
)
|
||||
|
||||
except IrmKmiApiError as err:
|
||||
if (
|
||||
self.last_update_success_time is not None
|
||||
and self.update_interval is not None
|
||||
and self.last_update_success_time - utcnow()
|
||||
< timedelta(seconds=2.5 * self.update_interval.seconds)
|
||||
):
|
||||
return self.data
|
||||
|
||||
_LOGGER.warning(
|
||||
"Could not connect to the API since %s", self.last_update_success_time
|
||||
)
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with API for general forecast: {err}. "
|
||||
f"Last success time is: {self.last_update_success_time}"
|
||||
) from err
|
||||
|
||||
if not self.last_update_success:
|
||||
_LOGGER.warning("Successfully reconnected to the API")
|
||||
|
||||
return await self.process_api_data()
|
||||
|
||||
async def process_api_data(self) -> ProcessedCoordinatorData:
|
||||
"""From the API data, create the object that will be used in the entities."""
|
||||
tz = await dt_util.async_get_time_zone("Europe/Brussels")
|
||||
lang = preferred_language(self.hass, self.config_entry)
|
||||
|
||||
return ProcessedCoordinatorData(
|
||||
current_weather=self._api.get_current_weather(tz),
|
||||
daily_forecast=self._api.get_daily_forecast(tz, lang),
|
||||
hourly_forecast=self._api.get_hourly_forecast(tz),
|
||||
country=self._api.get_country(),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Define data classes for the IRM KMI integration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from irm_kmi_api import CurrentWeatherData, ExtendedForecast
|
||||
|
||||
from homeassistant.components.weather import Forecast
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedCoordinatorData:
|
||||
"""Dataclass that will be exposed to the entities consuming data from an IrmKmiCoordinator."""
|
||||
|
||||
current_weather: CurrentWeatherData
|
||||
country: str
|
||||
hourly_forecast: list[Forecast] = field(default_factory=list)
|
||||
daily_forecast: list[ExtendedForecast] = field(default_factory=list)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Base class shared among IRM KMI entities."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, IRM_KMI_NAME
|
||||
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
|
||||
from .utils import preferred_language
|
||||
|
||||
|
||||
class IrmKmiBaseEntity(CoordinatorEntity[IrmKmiCoordinator]):
|
||||
"""Base methods for IRM KMI entities."""
|
||||
|
||||
_attr_attribution = (
|
||||
"Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||
)
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, entry: IrmKmiConfigEntry) -> None:
|
||||
"""Init base properties for IRM KMI entities."""
|
||||
coordinator = entry.runtime_data
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "irm_kmi",
|
||||
"name": "IRM KMI Weather Belgium",
|
||||
"codeowners": ["@jdejaegh"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["zone"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/irm_kmi",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["irm_kmi_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["irm-kmi-api==1.1.0"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: >
|
||||
No service action implemented in this integration at the moment.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: >
|
||||
Polling interval is set to 7 minutes.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: >
|
||||
No service action implemented in this integration at the moment.
|
||||
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: >
|
||||
No service action implemented in this integration at the moment.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: >
|
||||
There is no authentication for this integration
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: >
|
||||
The integration does not look for devices on the network. It uses an online API.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: >
|
||||
The integration does not look for devices on the network. It uses an online API.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: >
|
||||
This integration does not integrate physical devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: >
|
||||
There is no configuration per se, just a zone to pick.
|
||||
repair-issues: done
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"title": "Royal Meteorological Institute of Belgium",
|
||||
"common": {
|
||||
"language_override_description": "Override the Home Assistant language for the textual weather forecast."
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"api_error": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
},
|
||||
"data_description": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"out_of_benelux": "The location is outside of Benelux. Pick a location in Benelux."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"language_override": {
|
||||
"options": {
|
||||
"none": "Follow Home Assistant server language",
|
||||
"fr": "French",
|
||||
"nl": "Dutch",
|
||||
"de": "German",
|
||||
"en": "English"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Options",
|
||||
"data": {
|
||||
"language_override": "[%key:common::config_flow::data::language%]"
|
||||
},
|
||||
"data_description": {
|
||||
"language_override": "[%key:component::irm_kmi::common::language_override_description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Helper functions for use with IRM KMI integration."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_LANGUAGE_OVERRIDE, LANGS
|
||||
|
||||
|
||||
def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry | None) -> str:
|
||||
"""Get the preferred language for the integration if it was overridden by the configuration."""
|
||||
|
||||
if (
|
||||
config_entry is None
|
||||
or config_entry.options.get(CONF_LANGUAGE_OVERRIDE) == "none"
|
||||
):
|
||||
return hass.config.language if hass.config.language in LANGS else "en"
|
||||
|
||||
return config_entry.options.get(CONF_LANGUAGE_OVERRIDE, "en")
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Support for IRM KMI weather."""
|
||||
|
||||
from irm_kmi_api import CurrentWeatherData
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
Forecast,
|
||||
SingleCoordinatorWeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_UNIQUE_ID,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
|
||||
from .entity import IrmKmiBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
_hass: HomeAssistant,
|
||||
entry: IrmKmiConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the weather entry."""
|
||||
async_add_entities([IrmKmiWeather(entry)])
|
||||
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class IrmKmiWeather(
|
||||
IrmKmiBaseEntity, # WeatherEntity
|
||||
SingleCoordinatorWeatherEntity[IrmKmiCoordinator],
|
||||
):
|
||||
"""Weather entity for IRM KMI weather."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY
|
||||
| WeatherEntityFeature.FORECAST_TWICE_DAILY
|
||||
| WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
_attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
|
||||
def __init__(self, entry: IrmKmiConfigEntry) -> None:
|
||||
"""Create a new instance of the weather entity from a configuration entry."""
|
||||
IrmKmiBaseEntity.__init__(self, entry)
|
||||
SingleCoordinatorWeatherEntity.__init__(self, entry.runtime_data)
|
||||
self._attr_unique_id = entry.data[CONF_UNIQUE_ID]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def current_weather(self) -> CurrentWeatherData:
|
||||
"""Return the current weather."""
|
||||
return self.coordinator.data.current_weather
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return self.current_weather.get("condition")
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature in native units."""
|
||||
return self.current_weather.get("temperature")
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed in native units."""
|
||||
return self.current_weather.get("wind_speed")
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float | None:
|
||||
"""Return the wind gust speed in native units."""
|
||||
return self.current_weather.get("wind_gust_speed")
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the wind bearing."""
|
||||
return self.current_weather.get("wind_bearing")
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure in native units."""
|
||||
return self.current_weather.get("pressure")
|
||||
|
||||
@property
|
||||
def uv_index(self) -> float | None:
|
||||
"""Return the UV index."""
|
||||
return self.current_weather.get("uv_index")
|
||||
|
||||
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
return self.coordinator.data.daily_forecast
|
||||
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
return self.daily_forecast()
|
||||
|
||||
def _async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast in native units."""
|
||||
return self.coordinator.data.hourly_forecast
|
||||
|
||||
def daily_forecast(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
data: list[Forecast] = self.coordinator.data.daily_forecast
|
||||
|
||||
# The data in daily_forecast might contain nighttime forecast.
|
||||
# The following handle the lowest temperature attribute to be displayed correctly.
|
||||
if (
|
||||
len(data) > 1
|
||||
and not data[0].get("is_daytime")
|
||||
and data[1].get("native_templow") is None
|
||||
):
|
||||
data[1]["native_templow"] = data[0].get("native_templow")
|
||||
if (
|
||||
data[1]["native_templow"] is not None
|
||||
and data[1]["native_temperature"] is not None
|
||||
and data[1]["native_templow"] > data[1]["native_temperature"]
|
||||
):
|
||||
(data[1]["native_templow"], data[1]["native_temperature"]) = (
|
||||
data[1]["native_temperature"],
|
||||
data[1]["native_templow"],
|
||||
)
|
||||
|
||||
if len(data) > 0 and not data[0].get("is_daytime"):
|
||||
return data
|
||||
|
||||
if (
|
||||
len(data) > 1
|
||||
and data[0].get("native_templow") is None
|
||||
and not data[1].get("is_daytime")
|
||||
):
|
||||
data[0]["native_templow"] = data[1].get("native_templow")
|
||||
if (
|
||||
data[0]["native_templow"] is not None
|
||||
and data[0]["native_temperature"] is not None
|
||||
and data[0]["native_templow"] > data[0]["native_temperature"]
|
||||
):
|
||||
(data[0]["native_templow"], data[0]["native_temperature"]) = (
|
||||
data[0]["native_temperature"],
|
||||
data[0]["native_templow"],
|
||||
)
|
||||
|
||||
return [f for f in data if f.get("is_daytime")]
|
||||
@@ -14,5 +14,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pynecil"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pynecil==4.1.1"]
|
||||
"requirements": ["pynecil==4.2.0"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user