mirror of
https://github.com/home-assistant/core.git
synced 2026-05-05 20:34:52 +02:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5804307e7 | |||
| 3b74b63b23 | |||
| 06df32d9d4 | |||
| 63947e4980 | |||
| ac6a377478 | |||
| 18af423a78 | |||
| f1445bc8f5 | |||
| 3784c99305 | |||
| 0084d6c5bd | |||
| f1e6375406 | |||
| 9157905f80 | |||
| b02c9aa2ef | |||
| 6e30fd7633 | |||
| 74b29c2e54 | |||
| b1b26af92b | |||
| b107ffd30d | |||
| 776675404a | |||
| 38ee32fed2 | |||
| 111d11aaca | |||
| ff8752ea4f | |||
| 2151f7ebf3 | |||
| 50efce4e53 | |||
| c8538f3c08 | |||
| 4bfff12570 | |||
| f2b1635969 | |||
| b3b8ae31fd | |||
| ba96fc272b | |||
| c702174fa0 | |||
| 5d6fe7387e | |||
| c76b7a48d3 | |||
| 954e8ff9b3 | |||
| 8c332ddbdb | |||
| 01c4ca2749 | |||
| 4b4b5362d9 | |||
| 70d7cedf08 | |||
| 7bbfb1a22b | |||
| d68d871054 | |||
| 69bdefb02d | |||
| ebaec6380f | |||
| 9cf6e9b21a | |||
| eb1a9eda60 | |||
| 26344ffd74 | |||
| 2940104008 | |||
| 8072a268a1 | |||
| b5f557ad73 | |||
| f977b54312 | |||
| 11b2f201f3 | |||
| 8cc3c147fe | |||
| fd9ea2f224 | |||
| f064f44a09 | |||
| f3d1157bc4 | |||
| 85982d2b87 | |||
| cc83443ad1 | |||
| 8a516207e9 | |||
| f805df8390 | |||
| ea85ed6992 | |||
| 54425b756e | |||
| 7b43b587a7 | |||
| 7e71975358 | |||
| e0232510d7 | |||
| 84f9bb1d63 | |||
| b436fe94ae | |||
| aff5da5762 | |||
| b5783e6f5c | |||
| 1708b60ecf | |||
| 3c012c497b | |||
| 4d2dc9a40e | |||
| 3653a51288 | |||
| 9366a4e69b | |||
| 1d1af7ec11 | |||
| 236b19c5b3 | |||
| 1afbfd687f | |||
| 20159d0277 | |||
| 4df3d43e45 | |||
| 1a588760b9 | |||
| 6ba9e7d5fd | |||
| 4b06c5d2fb | |||
| bfc1c62a49 | |||
| c52fabcf77 | |||
| b39d7b39e1 | |||
| c01c155037 | |||
| b459559c8b | |||
| d823e56659 | |||
| e401a0da7f | |||
| 3f6df28ef3 | |||
| 9b63779063 | |||
| 4998fe5e6d | |||
| a59c890779 | |||
| a2cdb349f4 | |||
| 267228cae0 | |||
| ba769f4d9f | |||
| c09bc726d1 | |||
| c441f689bf | |||
| 395e1ae31e | |||
| 2e45d678b8 | |||
| 17cb25a5b6 |
@@ -62,7 +62,6 @@ omit =
|
||||
homeassistant/components/aladdin_connect/api.py
|
||||
homeassistant/components/aladdin_connect/application_credentials.py
|
||||
homeassistant/components/aladdin_connect/cover.py
|
||||
homeassistant/components/aladdin_connect/model.py
|
||||
homeassistant/components/aladdin_connect/sensor.py
|
||||
homeassistant/components/alarmdecoder/__init__.py
|
||||
homeassistant/components/alarmdecoder/alarm_control_panel.py
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ads",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyads"],
|
||||
"requirements": ["pyads==3.2.2"]
|
||||
"requirements": ["pyads==3.4.0"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from airgradient import AirGradientClient, AirGradientError
|
||||
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl
|
||||
from awesomeversion import AwesomeVersion
|
||||
from mashumaro import MissingField
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
@@ -12,6 +14,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
MIN_VERSION = AwesomeVersion("3.1.1")
|
||||
|
||||
|
||||
class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""AirGradient config flow."""
|
||||
@@ -19,6 +23,14 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.client: AirGradientClient | None = None
|
||||
|
||||
async def set_configuration_source(self) -> None:
|
||||
"""Set configuration source to local if it hasn't been set yet."""
|
||||
assert self.client
|
||||
config = await self.client.get_config()
|
||||
if config.configuration_control is ConfigurationControl.NOT_INITIALIZED:
|
||||
await self.client.set_configuration_control(ConfigurationControl.LOCAL)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
@@ -30,9 +42,12 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(discovery_info.properties["serialno"])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
if AwesomeVersion(discovery_info.properties["fw_ver"]) < MIN_VERSION:
|
||||
return self.async_abort(reason="invalid_version")
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
air_gradient = AirGradientClient(host, session=session)
|
||||
await air_gradient.get_current_measures()
|
||||
self.client = AirGradientClient(host, session=session)
|
||||
await self.client.get_current_measures()
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"model": self.data[CONF_MODEL],
|
||||
@@ -44,6 +59,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None:
|
||||
await self.set_configuration_source()
|
||||
return self.async_create_entry(
|
||||
title=self.data[CONF_MODEL],
|
||||
data={CONF_HOST: self.data[CONF_HOST]},
|
||||
@@ -64,14 +80,17 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input:
|
||||
session = async_get_clientsession(self.hass)
|
||||
air_gradient = AirGradientClient(user_input[CONF_HOST], session=session)
|
||||
self.client = AirGradientClient(user_input[CONF_HOST], session=session)
|
||||
try:
|
||||
current_measures = await air_gradient.get_current_measures()
|
||||
current_measures = await self.client.get_current_measures()
|
||||
except AirGradientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MissingField:
|
||||
return self.async_abort(reason="invalid_version")
|
||||
else:
|
||||
await self.async_set_unique_id(current_measures.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
await self.set_configuration_source()
|
||||
return self.async_create_entry(
|
||||
title=current_measures.model,
|
||||
data={CONF_HOST: user_input[CONF_HOST]},
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.4.2"],
|
||||
"requirements": ["airgradient==0.4.3"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from .entity import AirGradientEntity
|
||||
class AirGradientSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describes AirGradient select entity."""
|
||||
|
||||
value_fn: Callable[[Config], str]
|
||||
value_fn: Callable[[Config], str | None]
|
||||
set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]]
|
||||
requires_display: bool = False
|
||||
|
||||
@@ -30,9 +30,11 @@ class AirGradientSelectEntityDescription(SelectEntityDescription):
|
||||
CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
|
||||
key="configuration_control",
|
||||
translation_key="configuration_control",
|
||||
options=[x.value for x in ConfigurationControl],
|
||||
options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda config: config.configuration_control,
|
||||
value_fn=lambda config: config.configuration_control
|
||||
if config.configuration_control is not ConfigurationControl.NOT_INITIALIZED
|
||||
else None,
|
||||
set_value_fn=lambda client, value: client.set_configuration_control(
|
||||
ConfigurationControl(value)
|
||||
),
|
||||
@@ -96,7 +98,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the state of the select."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -28,8 +29,7 @@
|
||||
"name": "Configuration source",
|
||||
"state": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local",
|
||||
"both": "Both"
|
||||
"local": "Local"
|
||||
}
|
||||
},
|
||||
"display_temperature_unit": {
|
||||
|
||||
@@ -2,52 +2,93 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AladdinConnectCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.COVER]
|
||||
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
|
||||
|
||||
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
|
||||
coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth))
|
||||
|
||||
# If using an aiohttp-based API lib
|
||||
entry.runtime_data = api.AsyncConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), session
|
||||
)
|
||||
await coordinator.async_setup()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async_remove_stale_devices(hass, entry)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config."""
|
||||
if config_entry.version < CONFIG_FLOW_VERSION:
|
||||
if config_entry.version < 2:
|
||||
config_entry.async_start_reauth(hass)
|
||||
new_data = {**config_entry.data}
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data=new_data,
|
||||
version=CONFIG_FLOW_VERSION,
|
||||
minor_version=CONFIG_FLOW_MINOR_VERSION,
|
||||
version=2,
|
||||
minor_version=1,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def async_remove_stale_devices(
|
||||
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
|
||||
) -> None:
|
||||
"""Remove stale devices from device registry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors}
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
device_id = identifier[1]
|
||||
break
|
||||
|
||||
if device_id is None or device_id not in all_device_ids:
|
||||
# If device_id is None an invalid device entry was found for this config entry.
|
||||
# If the device_id is not in existing device ids it's a stale device entry.
|
||||
# Remove config entry from this device entry in either case.
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from genie_partner_sdk.auth import Auth
|
||||
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
|
||||
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
|
||||
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
|
||||
@@ -15,7 +17,7 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
oauth_session: OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize Aladdin Connect Genie auth."""
|
||||
super().__init__(
|
||||
@@ -25,7 +27,6 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return str(self._oauth_session.token["access_token"])
|
||||
return cast(str, self._oauth_session.token["access_token"])
|
||||
|
||||
@@ -4,22 +4,21 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
import jwt
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = CONFIG_FLOW_VERSION
|
||||
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 1
|
||||
|
||||
reauth_entry: ConfigEntry | None = None
|
||||
|
||||
@@ -37,20 +36,33 @@ class OAuth2FlowHandler(
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an oauth config entry or update existing entry for reauth."""
|
||||
if self.reauth_entry:
|
||||
token_payload = jwt.decode(
|
||||
data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False}
|
||||
)
|
||||
if not self.reauth_entry:
|
||||
await self.async_set_unique_id(token_payload["sub"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=token_payload["username"],
|
||||
data=data,
|
||||
)
|
||||
|
||||
if self.reauth_entry.unique_id == token_payload["username"]:
|
||||
return self.async_update_reload_and_abort(
|
||||
self.reauth_entry,
|
||||
data=data,
|
||||
unique_id=token_payload["sub"],
|
||||
)
|
||||
return await super().async_oauth_create_entry(data)
|
||||
if self.reauth_entry.unique_id == token_payload["sub"]:
|
||||
return self.async_update_reload_and_abort(self.reauth_entry, data=data)
|
||||
|
||||
return self.async_abort(reason="wrong_account")
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"""Constants for the Aladdin Connect Genie integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.cover import CoverEntityFeature
|
||||
|
||||
DOMAIN = "aladdin_connect"
|
||||
CONFIG_FLOW_VERSION = 2
|
||||
CONFIG_FLOW_MINOR_VERSION = 1
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
|
||||
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
|
||||
|
||||
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Define an object to coordinate fetching Aladdin Connect data."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Aladdin Connect Data Update Coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=15),
|
||||
)
|
||||
self.acc = acc
|
||||
self.doors: list[GarageDoor] = []
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Fetch initial data."""
|
||||
self.doors = await self.acc.get_doors()
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
for door in self.doors:
|
||||
await self.acc.update_door(door.device_id, door.door_number)
|
||||
@@ -1,115 +1,64 @@
|
||||
"""Cover Entity for Genie Garage Door."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.cover import (
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN, SUPPORTED_FEATURES
|
||||
from .model import GarageDoor
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: AladdinConnectConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Aladdin Connect platform."""
|
||||
session: api.AsyncConfigEntryAuth = config_entry.runtime_data
|
||||
acc = AladdinConnectClient(session)
|
||||
doors = await acc.get_doors()
|
||||
if doors is None:
|
||||
raise PlatformNotReady("Error from Aladdin Connect getting doors")
|
||||
device_registry = dr.async_get(hass)
|
||||
doors_to_add = []
|
||||
for door in doors:
|
||||
existing = device_registry.async_get(door.unique_id)
|
||||
if existing is None:
|
||||
doors_to_add.append(door)
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
(AladdinDevice(acc, door, config_entry) for door in doors_to_add),
|
||||
)
|
||||
remove_stale_devices(hass, config_entry, doors)
|
||||
async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors)
|
||||
|
||||
|
||||
def remove_stale_devices(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, devices: list[GarageDoor]
|
||||
) -> None:
|
||||
"""Remove stale devices from device registry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = {door.unique_id for door in devices}
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
device_id = identifier[1]
|
||||
break
|
||||
|
||||
if device_id is None or device_id not in all_device_ids:
|
||||
# If device_id is None an invalid device entry was found for this config entry.
|
||||
# If the device_id is not in existing device ids it's a stale device entry.
|
||||
# Remove config entry from this device entry in either case.
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
class AladdinDevice(CoverEntity):
|
||||
class AladdinDevice(AladdinConnectEntity, CoverEntity):
|
||||
"""Representation of Aladdin Connect cover."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.GARAGE
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, acc: AladdinConnectClient, device: GarageDoor, entry: ConfigEntry
|
||||
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
|
||||
) -> None:
|
||||
"""Initialize the Aladdin Connect cover."""
|
||||
self._acc = acc
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
name=device.name,
|
||||
manufacturer="Overhead Door",
|
||||
)
|
||||
super().__init__(coordinator, device)
|
||||
self._attr_unique_id = device.unique_id
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
await self._acc.open_door(self._device_id, self._number)
|
||||
await self.coordinator.acc.open_door(
|
||||
self._device.device_id, self._device.door_number
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue close command to cover."""
|
||||
await self._acc.close_door(self._device_id, self._number)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update status of cover."""
|
||||
await self._acc.update_door(self._device_id, self._number)
|
||||
await self.coordinator.acc.close_door(
|
||||
self._device.device_id, self._device.door_number
|
||||
)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
value = self._acc.get_door_status(self._device_id, self._number)
|
||||
value = self.coordinator.acc.get_door_status(
|
||||
self._device.device_id, self._device.door_number
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value == "closed")
|
||||
@@ -117,7 +66,9 @@ class AladdinDevice(CoverEntity):
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Update is closing attribute."""
|
||||
value = self._acc.get_door_status(self._device_id, self._number)
|
||||
value = self.coordinator.acc.get_door_status(
|
||||
self._device.device_id, self._device.door_number
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value == "closing")
|
||||
@@ -125,7 +76,9 @@ class AladdinDevice(CoverEntity):
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Update is opening attribute."""
|
||||
value = self._acc.get_door_status(self._device_id, self._number)
|
||||
value = self.coordinator.acc.get_door_status(
|
||||
self._device.device_id, self._device.door_number
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value == "opening")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Defines a base Aladdin Connect entity."""
|
||||
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AladdinConnectCoordinator
|
||||
|
||||
|
||||
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||
"""Defines a base Aladdin Connect entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
name=device.name,
|
||||
manufacturer="Overhead Door",
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Models for Aladdin connect cover platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class GarageDoorData(TypedDict):
|
||||
"""Aladdin door data."""
|
||||
|
||||
device_id: str
|
||||
door_number: int
|
||||
name: str
|
||||
status: str
|
||||
link_status: str
|
||||
battery_level: int
|
||||
|
||||
|
||||
class GarageDoor:
|
||||
"""Aladdin Garage Door Entity."""
|
||||
|
||||
def __init__(self, data: GarageDoorData) -> None:
|
||||
"""Create `GarageDoor` from dictionary of data."""
|
||||
self.device_id = data["device_id"]
|
||||
self.door_number = data["door_number"]
|
||||
self.unique_id = f"{self.device_id}-{self.door_number}"
|
||||
self.name = data["name"]
|
||||
self.status = data["status"]
|
||||
self.link_status = data["link_status"]
|
||||
self.battery_level = data["battery_level"]
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -14,22 +14,19 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN
|
||||
from .model import GarageDoor
|
||||
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes AladdinConnect sensor entity."""
|
||||
|
||||
value_fn: Callable
|
||||
value_fn: Callable[[AladdinConnectClient, str, int], float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[AccSensorEntityDescription, ...] = (
|
||||
@@ -45,52 +42,39 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = (
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aladdin Connect sensor devices."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
session: api.AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
|
||||
acc = AladdinConnectClient(session)
|
||||
|
||||
entities = []
|
||||
doors = await acc.get_doors()
|
||||
|
||||
for door in doors:
|
||||
entities.extend(
|
||||
[AladdinConnectSensor(acc, door, description) for description in SENSORS]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, door, description)
|
||||
for description in SENSORS
|
||||
for door in coordinator.doors
|
||||
)
|
||||
|
||||
|
||||
class AladdinConnectSensor(SensorEntity):
|
||||
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
"""A sensor implementation for Aladdin Connect devices."""
|
||||
|
||||
entity_description: AccSensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
acc: AladdinConnectClient,
|
||||
coordinator: AladdinConnectCoordinator,
|
||||
device: GarageDoor,
|
||||
description: AccSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a sensor for an Aladdin Connect device."""
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
self._acc = acc
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{device.unique_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
name=device.name,
|
||||
manufacturer="Overhead Door",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return cast(
|
||||
float,
|
||||
self.entity_description.value_fn(self._acc, self._device_id, self._number),
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.acc, self._device.device_id, self._device.door_number
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"save_recent_clips": {
|
||||
"name": "Save recent clips",
|
||||
"description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".",
|
||||
"description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_[camera name].mp4\".",
|
||||
"fields": {
|
||||
"file_path": {
|
||||
"name": "Output directory",
|
||||
|
||||
@@ -65,11 +65,13 @@ class BMWLock(BMWBaseEntity, LockEntity):
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_lock()
|
||||
except MyBMWAPIError as ex:
|
||||
self._attr_is_locked = False
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(ex) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the car."""
|
||||
@@ -83,11 +85,13 @@ class BMWLock(BMWBaseEntity, LockEntity):
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_unlock()
|
||||
except MyBMWAPIError as ex:
|
||||
self._attr_is_locked = True
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(ex) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
|
||||
@@ -6,9 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from bimmer_connected.models import ValueWithUnit
|
||||
from bimmer_connected.models import StrEnum, ValueWithUnit
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -18,14 +17,19 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfLength,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import BMWBaseEntity
|
||||
from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP
|
||||
from .const import CLIMATE_ACTIVITY_STATE, DOMAIN
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -36,34 +40,18 @@ class BMWSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
key_class: str | None = None
|
||||
unit_type: str | None = None
|
||||
value: Callable = lambda x, y: x
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
|
||||
|
||||
|
||||
def convert_and_round(
|
||||
state: ValueWithUnit,
|
||||
converter: Callable[[float | None, str], float],
|
||||
precision: int,
|
||||
) -> float | None:
|
||||
"""Safely convert and round a value from ValueWithUnit."""
|
||||
if state.value and state.unit:
|
||||
return round(
|
||||
converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision
|
||||
)
|
||||
if state.value:
|
||||
return state.value
|
||||
return None
|
||||
|
||||
|
||||
SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
# --- Generic ---
|
||||
BMWSensorEntityDescription(
|
||||
key="ac_current_limit",
|
||||
translation_key="ac_current_limit",
|
||||
key_class="charging_profile",
|
||||
unit_type=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
@@ -85,74 +73,81 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
key="charging_status",
|
||||
translation_key="charging_status",
|
||||
key_class="fuel_and_battery",
|
||||
value=lambda x, y: x.value,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="charging_target",
|
||||
translation_key="charging_target",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_battery_percent",
|
||||
translation_key="remaining_battery_percent",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
# --- Specific ---
|
||||
BMWSensorEntityDescription(
|
||||
key="mileage",
|
||||
translation_key="mileage",
|
||||
unit_type=LENGTH,
|
||||
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_range_total",
|
||||
translation_key="remaining_range_total",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=LENGTH,
|
||||
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_range_electric",
|
||||
translation_key="remaining_range_electric",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=LENGTH,
|
||||
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_range_fuel",
|
||||
translation_key="remaining_range_fuel",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=LENGTH,
|
||||
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_fuel",
|
||||
translation_key="remaining_fuel",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=VOLUME,
|
||||
value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2),
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="remaining_fuel_percent",
|
||||
translation_key="remaining_fuel_percent",
|
||||
key_class="fuel_and_battery",
|
||||
unit_type=PERCENTAGE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
@@ -161,7 +156,6 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
key_class="climate",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=CLIMATE_ACTIVITY_STATE,
|
||||
value=lambda x, _: x.lower() if x != "UNKNOWN" else None,
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
),
|
||||
]
|
||||
@@ -201,13 +195,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
# Set the correct unit of measurement based on the unit_type
|
||||
if description.unit_type:
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.hass.config.units.as_dict().get(description.unit_type)
|
||||
or description.unit_type
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
@@ -225,8 +212,18 @@ class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||
# For datetime without tzinfo, we assume it to be the same timezone as the HA instance
|
||||
if isinstance(state, datetime.datetime) and state.tzinfo is None:
|
||||
state = state.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
# For enum types, we only want the value
|
||||
elif isinstance(state, ValueWithUnit):
|
||||
state = state.value
|
||||
# Get lowercase values from StrEnum
|
||||
elif isinstance(state, StrEnum):
|
||||
state = state.value.lower()
|
||||
if state == STATE_UNKNOWN:
|
||||
state = None
|
||||
|
||||
self._attr_native_value = cast(
|
||||
StateType, self.entity_description.value(state, self.hass)
|
||||
)
|
||||
# special handling for charging_status to avoid a breaking change
|
||||
if self.entity_description.key == "charging_status" and state:
|
||||
state = state.upper()
|
||||
|
||||
self._attr_native_value = state
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from brother import Brother, SnmpError
|
||||
from pysnmp.hlapi.asyncio.cmdgen import lcd
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.components.snmp import async_get_snmp_engine
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, SNMP_ENGINE
|
||||
from .coordinator import BrotherDataUpdateCoordinator
|
||||
from .utils import get_snmp_engine
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
@@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
|
||||
host = entry.data[CONF_HOST]
|
||||
printer_type = entry.data[CONF_TYPE]
|
||||
|
||||
snmp_engine = get_snmp_engine(hass)
|
||||
snmp_engine = await async_get_snmp_engine(hass)
|
||||
try:
|
||||
brother = await Brother.create(
|
||||
host, printer_type=printer_type, snmp_engine=snmp_engine
|
||||
@@ -44,16 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
# We only want to remove the SNMP engine when unloading the last config entry
|
||||
if unload_ok and len(loaded_entries) == 1:
|
||||
lcd.unconfigure(hass.data[SNMP_ENGINE], None)
|
||||
hass.data.pop(SNMP_ENGINE)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -8,13 +8,13 @@ from brother import Brother, SnmpError, UnsupportedModelError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.snmp import async_get_snmp_engine
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_TYPE
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.network import is_host_valid
|
||||
|
||||
from .const import DOMAIN, PRINTER_TYPES
|
||||
from .utils import get_snmp_engine
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -45,7 +45,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not is_host_valid(user_input[CONF_HOST]):
|
||||
raise InvalidHost
|
||||
|
||||
snmp_engine = get_snmp_engine(self.hass)
|
||||
snmp_engine = await async_get_snmp_engine(self.hass)
|
||||
|
||||
brother = await Brother.create(
|
||||
user_input[CONF_HOST], snmp_engine=snmp_engine
|
||||
@@ -79,7 +79,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# Do not probe the device if the host is already configured
|
||||
self._async_abort_entries_match({CONF_HOST: self.host})
|
||||
|
||||
snmp_engine = get_snmp_engine(self.hass)
|
||||
snmp_engine = await async_get_snmp_engine(self.hass)
|
||||
model = discovery_info.properties.get("product")
|
||||
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,4 @@ DOMAIN: Final = "brother"
|
||||
|
||||
PRINTER_TYPES: Final = ["laser", "ink"]
|
||||
|
||||
SNMP_ENGINE: Final = "snmp_engine"
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "brother",
|
||||
"name": "Brother Printer",
|
||||
"after_dependencies": ["snmp"],
|
||||
"codeowners": ["@bieniu"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/brother",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Brother helpers functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pysnmp.hlapi.asyncio as hlapi
|
||||
from pysnmp.hlapi.asyncio.cmdgen import lcd
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import singleton
|
||||
|
||||
from .const import SNMP_ENGINE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@singleton.singleton(SNMP_ENGINE)
|
||||
def get_snmp_engine(hass: HomeAssistant) -> hlapi.SnmpEngine:
|
||||
"""Get SNMP engine."""
|
||||
_LOGGER.debug("Creating SNMP engine")
|
||||
snmp_engine = hlapi.SnmpEngine()
|
||||
|
||||
@callback
|
||||
def shutdown_listener(ev: Event) -> None:
|
||||
if hass.data.get(SNMP_ENGINE):
|
||||
_LOGGER.debug("Unconfiguring SNMP engine")
|
||||
lcd.unconfigure(hass.data[SNMP_ENGINE], None)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener)
|
||||
|
||||
return snmp_engine
|
||||
@@ -57,6 +57,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
HVACMode.HEAT,
|
||||
HVACMode.COOL,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
]
|
||||
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["hass_nabucasa"],
|
||||
"requirements": ["hass-nabucasa==0.81.0"]
|
||||
"requirements": ["hass-nabucasa==0.81.1"]
|
||||
}
|
||||
|
||||
@@ -871,7 +871,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if device_area is None:
|
||||
return None
|
||||
|
||||
return {"area": {"value": device_area.id, "text": device_area.name}}
|
||||
return {"area": {"value": device_area.name, "text": device_area.name}}
|
||||
|
||||
def _get_error_text(
|
||||
self,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.1", "home-assistant-intents==2024.5.28"]
|
||||
"requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.3"]
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DOMAIN = "discovergy"
|
||||
MANUFACTURER = "Discovergy"
|
||||
MANUFACTURER = "inexogy"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "discovergy",
|
||||
"name": "Discovergy",
|
||||
"name": "inexogy",
|
||||
"codeowners": ["@jpbede"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/discovergy",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"api_endpoint_reachable": "Discovergy API endpoint reachable"
|
||||
"api_endpoint_reachable": "inexogy API endpoint reachable"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -149,7 +149,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
# case the device doesn't have a static and unique UDN (breaking the
|
||||
# UPnP spec).
|
||||
for entry in self._async_current_entries(include_ignore=True):
|
||||
if self._location == entry.data[CONF_URL]:
|
||||
if self._location == entry.data.get(CONF_URL):
|
||||
return self.async_abort(reason="already_configured")
|
||||
if self._mac and self._mac == entry.data.get(CONF_MAC):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
@@ -43,7 +43,9 @@ class EcobeeNotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message and raise issue."""
|
||||
migrate_notify_issue(self.hass, DOMAIN, "Ecobee", "2024.11.0")
|
||||
migrate_notify_issue(
|
||||
self.hass, DOMAIN, "Ecobee", "2024.11.0", service_name=self._service_name
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.send_message, message, **kwargs)
|
||||
)
|
||||
|
||||
@@ -741,16 +741,18 @@ class EvoChild(EvoDevice):
|
||||
assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check
|
||||
|
||||
try:
|
||||
self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment]
|
||||
schedule = await self._evo_broker.call_client_api(
|
||||
self._evo_device.get_schedule(), update_state=False
|
||||
)
|
||||
except evo.InvalidSchedule as err:
|
||||
_LOGGER.warning(
|
||||
"%s: Unable to retrieve the schedule: %s",
|
||||
"%s: Unable to retrieve a valid schedule: %s",
|
||||
self._evo_device,
|
||||
err,
|
||||
)
|
||||
self._schedule = {}
|
||||
else:
|
||||
self._schedule = schedule or {}
|
||||
|
||||
_LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule)
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ class FileNotificationService(BaseNotificationService):
|
||||
"""Send a message to a file."""
|
||||
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
|
||||
# and will be removed with HA Core 2024.12
|
||||
migrate_notify_issue(self.hass, DOMAIN, "File", "2024.12.0")
|
||||
migrate_notify_issue(
|
||||
self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.send_message, message, **kwargs)
|
||||
)
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240530.0"]
|
||||
"requirements": ["home-assistant-frontend==20240605.0"]
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
}
|
||||
)
|
||||
|
||||
model_name = "gemini-pro-vision" if image_filenames else "gemini-pro"
|
||||
model = genai.GenerativeModel(model_name=model_name)
|
||||
model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
try:
|
||||
response = await model.generate_content_async(prompt_parts)
|
||||
|
||||
@@ -163,20 +163,22 @@ class GoogleGenerativeAIConversationEntity(
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
llm_api: llm.APIInstance | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
user_name: str | None = None
|
||||
llm_context = llm.LLMContext(
|
||||
platform=DOMAIN,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=conversation.DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
try:
|
||||
llm_api = await llm.async_get_api(
|
||||
self.hass,
|
||||
self.entry.options[CONF_LLM_HASS_API],
|
||||
llm.ToolContext(
|
||||
platform=DOMAIN,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=conversation.DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
),
|
||||
llm_context,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.error("Error getting LLM API: %s", err)
|
||||
@@ -223,7 +225,16 @@ class GoogleGenerativeAIConversationEntity(
|
||||
messages = self.history[conversation_id]
|
||||
else:
|
||||
conversation_id = ulid.ulid_now()
|
||||
messages = [{}, {}]
|
||||
messages = [{}, {"role": "model", "parts": "Ok"}]
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
||||
)
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
if llm_api:
|
||||
@@ -234,13 +245,16 @@ class GoogleGenerativeAIConversationEntity(
|
||||
prompt = "\n".join(
|
||||
(
|
||||
template.Template(
|
||||
self.entry.options.get(
|
||||
llm.BASE_PROMPT
|
||||
+ self.entry.options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
),
|
||||
@@ -258,8 +272,11 @@ class GoogleGenerativeAIConversationEntity(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
messages[0] = {"role": "user", "parts": prompt}
|
||||
messages[1] = {"role": "model", "parts": "Ok"}
|
||||
# Make a copy, because we attach it to the trace event.
|
||||
messages = [
|
||||
{"role": "user", "parts": prompt},
|
||||
*messages[1:],
|
||||
]
|
||||
|
||||
LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages)
|
||||
trace.async_conversation_trace_append(
|
||||
@@ -341,7 +358,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
chat_request = glm.Content(parts=tool_responses)
|
||||
|
||||
intent_response.async_set_speech(
|
||||
" ".join([part.text for part in chat_response.parts if part.text])
|
||||
" ".join([part.text.strip() for part in chat_response.parts if part.text])
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
|
||||
@@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = {
|
||||
"fan",
|
||||
"humidifier",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"scene",
|
||||
"script",
|
||||
"switch",
|
||||
"todo",
|
||||
"vacuum",
|
||||
|
||||
@@ -24,13 +24,17 @@ class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Hydrawise binary sensor."""
|
||||
|
||||
value_fn: Callable[[HydrawiseBinarySensor], bool | None]
|
||||
always_available: bool = False
|
||||
|
||||
|
||||
CONTROLLER_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = (
|
||||
HydrawiseBinarySensorEntityDescription(
|
||||
key="status",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success,
|
||||
value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success
|
||||
and status_sensor.controller.online,
|
||||
# Connectivtiy sensor is always available
|
||||
always_available=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -98,3 +102,10 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity):
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
self._attr_is_on = self.entity_description.value_fn(self)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Set the entity availability."""
|
||||
if self.entity_description.always_available:
|
||||
return True
|
||||
return super().available
|
||||
|
||||
@@ -70,3 +70,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
|
||||
self.controller = self.coordinator.data.controllers[self.controller.id]
|
||||
self._update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Set the entity availability."""
|
||||
return super().available and self.controller.online
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2024.4.1"]
|
||||
"requirements": ["pydrawise==2024.6.2"]
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2024.10.0",
|
||||
issue_domain=DOMAIN,
|
||||
breaks_in_ha_version="2024.12.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
@@ -118,10 +119,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
"""Set up a configuration entry for Jewish calendar."""
|
||||
language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE)
|
||||
diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA)
|
||||
candle_lighting_offset = config_entry.data.get(
|
||||
candle_lighting_offset = config_entry.options.get(
|
||||
CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT
|
||||
)
|
||||
havdalah_offset = config_entry.data.get(
|
||||
havdalah_offset = config_entry.options.get(
|
||||
CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES
|
||||
)
|
||||
|
||||
@@ -153,6 +154,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Trigger update of states for all platforms
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -100,10 +100,23 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
_options = {}
|
||||
if CONF_CANDLE_LIGHT_MINUTES in user_input:
|
||||
_options[CONF_CANDLE_LIGHT_MINUTES] = user_input[
|
||||
CONF_CANDLE_LIGHT_MINUTES
|
||||
]
|
||||
del user_input[CONF_CANDLE_LIGHT_MINUTES]
|
||||
if CONF_HAVDALAH_OFFSET_MINUTES in user_input:
|
||||
_options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[
|
||||
CONF_HAVDALAH_OFFSET_MINUTES
|
||||
]
|
||||
del user_input[CONF_HAVDALAH_OFFSET_MINUTES]
|
||||
if CONF_LOCATION in user_input:
|
||||
user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE]
|
||||
user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE]
|
||||
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_NAME, data=user_input, options=_options
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -60,7 +60,9 @@ class KNXNotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a notification to knx bus."""
|
||||
migrate_notify_issue(self.hass, DOMAIN, "KNX", "2024.11.0")
|
||||
migrate_notify_issue(
|
||||
self.hass, DOMAIN, "KNX", "2024.11.0", service_name=self._service_name
|
||||
)
|
||||
if "target" in kwargs:
|
||||
await self._async_send_to_device(message, kwargs["target"])
|
||||
else:
|
||||
|
||||
@@ -23,6 +23,7 @@ turn_on:
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
example: "[255, 100, 100]"
|
||||
selector:
|
||||
color_rgb:
|
||||
rgbw_color:
|
||||
@@ -250,6 +251,7 @@ turn_on:
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
selector:
|
||||
color_temp:
|
||||
unit: "mired"
|
||||
@@ -265,7 +267,6 @@ turn_on:
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
selector:
|
||||
color_temp:
|
||||
unit: "kelvin"
|
||||
@@ -419,10 +420,35 @@ toggle:
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
example: "[255, 100, 100]"
|
||||
selector:
|
||||
color_rgb:
|
||||
rgbw_color:
|
||||
filter:
|
||||
attribute:
|
||||
supported_color_modes:
|
||||
- light.ColorMode.HS
|
||||
- light.ColorMode.XY
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
example: "[255, 100, 100, 50]"
|
||||
selector:
|
||||
object:
|
||||
rgbww_color:
|
||||
filter:
|
||||
attribute:
|
||||
supported_color_modes:
|
||||
- light.ColorMode.HS
|
||||
- light.ColorMode.XY
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
example: "[255, 100, 100, 50, 70]"
|
||||
selector:
|
||||
object:
|
||||
color_name:
|
||||
filter:
|
||||
attribute:
|
||||
@@ -625,6 +651,9 @@ toggle:
|
||||
advanced: true
|
||||
selector:
|
||||
color_temp:
|
||||
unit: "mired"
|
||||
min: 153
|
||||
max: 500
|
||||
kelvin:
|
||||
filter:
|
||||
attribute:
|
||||
@@ -635,7 +664,6 @@ toggle:
|
||||
- light.ColorMode.RGB
|
||||
- light.ColorMode.RGBW
|
||||
- light.ColorMode.RGBWW
|
||||
advanced: true
|
||||
selector:
|
||||
color_temp:
|
||||
unit: "kelvin"
|
||||
|
||||
@@ -342,6 +342,14 @@
|
||||
"name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]",
|
||||
"description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]"
|
||||
},
|
||||
"rgbw_color": {
|
||||
"name": "[%key:component::light::services::turn_on::fields::rgbw_color::name%]",
|
||||
"description": "[%key:component::light::services::turn_on::fields::rgbw_color::description%]"
|
||||
},
|
||||
"rgbww_color": {
|
||||
"name": "[%key:component::light::services::turn_on::fields::rgbww_color::name%]",
|
||||
"description": "[%key:component::light::services::turn_on::fields::rgbww_color::description%]"
|
||||
},
|
||||
"color_name": {
|
||||
"name": "[%key:component::light::services::turn_on::fields::color_name::name%]",
|
||||
"description": "[%key:component::light::services::turn_on::fields::color_name::description%]"
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==6.1.0b1"],
|
||||
"requirements": ["python-matter-server==6.1.0"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2024.11.0",
|
||||
breaks_in_ha_version="2024.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
|
||||
@@ -33,7 +33,6 @@ ABBREVIATIONS = {
|
||||
"cmd_on_tpl": "command_on_template",
|
||||
"cmd_t": "command_topic",
|
||||
"cmd_tpl": "command_template",
|
||||
"cmp": "components",
|
||||
"cod_arm_req": "code_arm_required",
|
||||
"cod_dis_req": "code_disarm_required",
|
||||
"cod_form": "code_format",
|
||||
|
||||
@@ -86,7 +86,6 @@ CONF_TEMP_MIN = "min_temp"
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_COMPONENTS = "components"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
|
||||
# Device and integration info options
|
||||
|
||||
@@ -10,8 +10,6 @@ import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
|
||||
from homeassistant.core import HassJobType, HomeAssistant, callback
|
||||
@@ -21,7 +19,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo, ReceivePayloadType
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
from homeassistant.loader import async_get_mqtt
|
||||
from homeassistant.util.json import json_loads_object
|
||||
@@ -34,21 +32,19 @@ from .const import (
|
||||
ATTR_DISCOVERY_PAYLOAD,
|
||||
ATTR_DISCOVERY_TOPIC,
|
||||
CONF_AVAILABILITY,
|
||||
CONF_COMPONENTS,
|
||||
CONF_ORIGIN,
|
||||
CONF_TOPIC,
|
||||
DOMAIN,
|
||||
SUPPORTED_COMPONENTS,
|
||||
)
|
||||
from .models import DATA_MQTT, MqttComponentConfig, MqttOriginInfo, ReceiveMessage
|
||||
from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS
|
||||
from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage
|
||||
from .schemas import MQTT_ORIGIN_INFO_SCHEMA
|
||||
from .util import async_forward_entry_setup_and_setup_discovery
|
||||
|
||||
ABBREVIATIONS_SET = set(ABBREVIATIONS)
|
||||
DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS)
|
||||
ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TOPIC_MATCHER = re.compile(
|
||||
@@ -72,7 +68,6 @@ TOPIC_BASE = "~"
|
||||
class MQTTDiscoveryPayload(dict[str, Any]):
|
||||
"""Class to hold and MQTT discovery payload and discovery data."""
|
||||
|
||||
device_discovery: bool = False
|
||||
discovery_data: DiscoveryInfoType
|
||||
|
||||
|
||||
@@ -91,13 +86,9 @@ def async_log_discovery_origin_info(
|
||||
message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO
|
||||
) -> None:
|
||||
"""Log information about the discovery and origin."""
|
||||
# We only log origin info once per device discovery
|
||||
if not _LOGGER.isEnabledFor(level):
|
||||
# bail early if logging is disabled
|
||||
return
|
||||
if discovery_payload.device_discovery:
|
||||
_LOGGER.log(level, message)
|
||||
return
|
||||
if CONF_ORIGIN not in discovery_payload:
|
||||
_LOGGER.log(level, message)
|
||||
return
|
||||
@@ -177,65 +168,6 @@ def _replace_topic_base(discovery_payload: dict[str, Any]) -> None:
|
||||
availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}"
|
||||
|
||||
|
||||
@callback
|
||||
def _generate_device_cleanup_config(
|
||||
hass: HomeAssistant, object_id: str, node_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a cleanup message on device cleanup."""
|
||||
mqtt_data = hass.data[DATA_MQTT]
|
||||
device_node_id: str = f"{node_id} {object_id}" if node_id else object_id
|
||||
config: dict[str, Any] = {CONF_DEVICE: {}, CONF_COMPONENTS: {}}
|
||||
comp_config = config[CONF_COMPONENTS]
|
||||
for platform, discover_id in mqtt_data.discovery_already_discovered:
|
||||
ids = discover_id.split(" ")
|
||||
component_node_id = ids.pop(0)
|
||||
component_object_id = " ".join(ids)
|
||||
if not ids:
|
||||
continue
|
||||
if device_node_id == component_node_id:
|
||||
comp_config[component_object_id] = {CONF_PLATFORM: platform}
|
||||
|
||||
return config if comp_config else {}
|
||||
|
||||
|
||||
@callback
|
||||
def _parse_device_payload(
|
||||
hass: HomeAssistant,
|
||||
payload: ReceivePayloadType,
|
||||
object_id: str,
|
||||
node_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Parse a device discovery payload."""
|
||||
device_payload: dict[str, Any] = {}
|
||||
if payload == "":
|
||||
if not (
|
||||
device_payload := _generate_device_cleanup_config(hass, object_id, node_id)
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"No device components to cleanup for %s, node_id '%s'",
|
||||
object_id,
|
||||
node_id,
|
||||
)
|
||||
return device_payload
|
||||
try:
|
||||
device_payload = MQTTDiscoveryPayload(json_loads_object(payload))
|
||||
except ValueError:
|
||||
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
|
||||
return {}
|
||||
_replace_all_abbreviations(device_payload)
|
||||
try:
|
||||
DEVICE_DISCOVERY_SCHEMA(device_payload)
|
||||
except vol.Invalid as exc:
|
||||
_LOGGER.warning(
|
||||
"Invalid MQTT device discovery payload for %s, %s: '%s'",
|
||||
object_id,
|
||||
exc,
|
||||
payload,
|
||||
)
|
||||
return {}
|
||||
return device_payload
|
||||
|
||||
|
||||
@callback
|
||||
def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
|
||||
"""Parse and validate origin info from a single component discovery payload."""
|
||||
@@ -253,16 +185,6 @@ def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def _merge_common_options(
|
||||
component_config: MQTTDiscoveryPayload, device_config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Merge common options with the component config options."""
|
||||
for option in SHARED_OPTIONS:
|
||||
if option in device_config and option not in component_config:
|
||||
component_config[option] = device_config.get(option)
|
||||
|
||||
|
||||
async def async_start( # noqa: C901
|
||||
hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry
|
||||
) -> None:
|
||||
@@ -306,7 +228,8 @@ async def async_start( # noqa: C901
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Received message on illegal discovery topic '%s'. The topic"
|
||||
" contains not allowed characters. For more information see "
|
||||
" contains "
|
||||
"not allowed characters. For more information see "
|
||||
"https://www.home-assistant.io/integrations/mqtt/#discovery-topic"
|
||||
),
|
||||
topic,
|
||||
@@ -315,114 +238,55 @@ async def async_start( # noqa: C901
|
||||
|
||||
component, node_id, object_id = match.groups()
|
||||
|
||||
discovered_components: list[MqttComponentConfig] = []
|
||||
if component == CONF_DEVICE:
|
||||
# Process device based discovery message
|
||||
# and regenate cleanup config.
|
||||
device_discovery_payload = _parse_device_payload(
|
||||
hass, payload, object_id, node_id
|
||||
)
|
||||
if not device_discovery_payload:
|
||||
return
|
||||
device_config: dict[str, Any]
|
||||
origin_config: dict[str, Any] | None
|
||||
component_configs: dict[str, dict[str, Any]]
|
||||
device_config = device_discovery_payload[CONF_DEVICE]
|
||||
origin_config = device_discovery_payload.get(CONF_ORIGIN)
|
||||
component_configs = device_discovery_payload[CONF_COMPONENTS]
|
||||
for component_id, config in component_configs.items():
|
||||
component = config.pop(CONF_PLATFORM)
|
||||
# The object_id in the device discovery topic is the unique identifier.
|
||||
# It is used as node_id for the components it contains.
|
||||
component_node_id = object_id
|
||||
# The component_id in the discovery playload is used as object_id
|
||||
# If we have an additional node_id in the discovery topic,
|
||||
# we extend the component_id with it.
|
||||
component_object_id = (
|
||||
f"{node_id} {component_id}" if node_id else component_id
|
||||
)
|
||||
_replace_all_abbreviations(config)
|
||||
# We add wrapper to the discovery payload with the discovery data.
|
||||
# If the dict is empty after removing the platform, the payload is
|
||||
# assumed to remove the existing config and we do not want to add
|
||||
# device or orig or shared availability attributes.
|
||||
if discovery_payload := MQTTDiscoveryPayload(config):
|
||||
discovery_payload.device_discovery = True
|
||||
discovery_payload[CONF_DEVICE] = device_config
|
||||
discovery_payload[CONF_ORIGIN] = origin_config
|
||||
# Only assign shared config options
|
||||
# when they are not set at entity level
|
||||
_merge_common_options(discovery_payload, device_discovery_payload)
|
||||
discovered_components.append(
|
||||
MqttComponentConfig(
|
||||
component,
|
||||
component_object_id,
|
||||
component_node_id,
|
||||
discovery_payload,
|
||||
)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Process device discovery payload %s", device_discovery_payload
|
||||
)
|
||||
device_discovery_id = f"{node_id} {object_id}" if node_id else object_id
|
||||
message = f"Processing device discovery for '{device_discovery_id}'"
|
||||
async_log_discovery_origin_info(
|
||||
message, MQTTDiscoveryPayload(device_discovery_payload)
|
||||
)
|
||||
if component not in SUPPORTED_COMPONENTS:
|
||||
_LOGGER.warning("Integration %s is not supported", component)
|
||||
return
|
||||
|
||||
else:
|
||||
# Process component based discovery message
|
||||
if payload:
|
||||
try:
|
||||
discovery_payload = MQTTDiscoveryPayload(
|
||||
json_loads_object(payload) if payload else {}
|
||||
)
|
||||
discovery_payload = MQTTDiscoveryPayload(json_loads_object(payload))
|
||||
except ValueError:
|
||||
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
|
||||
return
|
||||
_replace_all_abbreviations(discovery_payload)
|
||||
if not _valid_origin_info(discovery_payload):
|
||||
return
|
||||
discovered_components.append(
|
||||
MqttComponentConfig(component, object_id, node_id, discovery_payload)
|
||||
)
|
||||
|
||||
discovery_pending_discovered = mqtt_data.discovery_pending_discovered
|
||||
for component_config in discovered_components:
|
||||
component = component_config.component
|
||||
node_id = component_config.node_id
|
||||
object_id = component_config.object_id
|
||||
discovery_payload = component_config.discovery_payload
|
||||
if component not in SUPPORTED_COMPONENTS:
|
||||
_LOGGER.warning("Integration %s is not supported", component)
|
||||
return
|
||||
|
||||
if TOPIC_BASE in discovery_payload:
|
||||
_replace_topic_base(discovery_payload)
|
||||
else:
|
||||
discovery_payload = MQTTDiscoveryPayload({})
|
||||
|
||||
# If present, the node_id will be included in the discovery_id.
|
||||
discovery_id = f"{node_id} {object_id}" if node_id else object_id
|
||||
discovery_hash = (component, discovery_id)
|
||||
# If present, the node_id will be included in the discovered object id
|
||||
discovery_id = f"{node_id} {object_id}" if node_id else object_id
|
||||
discovery_hash = (component, discovery_id)
|
||||
|
||||
if discovery_payload:
|
||||
# Attach MQTT topic to the payload, used for debug prints
|
||||
discovery_data = {
|
||||
ATTR_DISCOVERY_HASH: discovery_hash,
|
||||
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
|
||||
ATTR_DISCOVERY_TOPIC: topic,
|
||||
}
|
||||
setattr(discovery_payload, "discovery_data", discovery_data)
|
||||
if discovery_payload:
|
||||
# Attach MQTT topic to the payload, used for debug prints
|
||||
setattr(
|
||||
discovery_payload,
|
||||
"__configuration_source__",
|
||||
f"MQTT (topic: '{topic}')",
|
||||
)
|
||||
discovery_data = {
|
||||
ATTR_DISCOVERY_HASH: discovery_hash,
|
||||
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
|
||||
ATTR_DISCOVERY_TOPIC: topic,
|
||||
}
|
||||
setattr(discovery_payload, "discovery_data", discovery_data)
|
||||
|
||||
if discovery_hash in discovery_pending_discovered:
|
||||
pending = discovery_pending_discovered[discovery_hash]["pending"]
|
||||
pending.appendleft(discovery_payload)
|
||||
_LOGGER.debug(
|
||||
"Component has already been discovered: %s %s, queuing update",
|
||||
component,
|
||||
discovery_id,
|
||||
)
|
||||
return
|
||||
discovery_payload[CONF_PLATFORM] = "mqtt"
|
||||
|
||||
async_process_discovery_payload(component, discovery_id, discovery_payload)
|
||||
if discovery_hash in mqtt_data.discovery_pending_discovered:
|
||||
pending = mqtt_data.discovery_pending_discovered[discovery_hash]["pending"]
|
||||
pending.appendleft(discovery_payload)
|
||||
_LOGGER.debug(
|
||||
"Component has already been discovered: %s %s, queuing update",
|
||||
component,
|
||||
discovery_id,
|
||||
)
|
||||
return
|
||||
|
||||
async_process_discovery_payload(component, discovery_id, discovery_payload)
|
||||
|
||||
@callback
|
||||
def async_process_discovery_payload(
|
||||
@@ -430,7 +294,7 @@ async def async_start( # noqa: C901
|
||||
) -> None:
|
||||
"""Process the payload of a new discovery."""
|
||||
|
||||
_LOGGER.debug("Process component discovery payload %s", payload)
|
||||
_LOGGER.debug("Process discovery payload %s", payload)
|
||||
discovery_hash = (component, discovery_id)
|
||||
|
||||
already_discovered = discovery_hash in mqtt_data.discovery_already_discovered
|
||||
|
||||
@@ -682,7 +682,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC):
|
||||
self._config_entry = config_entry
|
||||
self._config_entry_id = config_entry.entry_id
|
||||
self._skip_device_removal: bool = False
|
||||
self._migrate_discovery: str | None = None
|
||||
|
||||
discovery_hash = get_discovery_hash(discovery_data)
|
||||
self._remove_discovery_updated = async_dispatcher_connect(
|
||||
@@ -721,24 +720,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC):
|
||||
discovery_hash,
|
||||
discovery_payload,
|
||||
)
|
||||
if not discovery_payload and self._migrate_discovery is not None:
|
||||
# Ignore empty update from migrated and removed discovery config.
|
||||
self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery
|
||||
self._migrate_discovery = None
|
||||
_LOGGER.info("Component successfully migrated: %s", discovery_hash)
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
return
|
||||
|
||||
if discovery_payload and (
|
||||
(discovery_topic := discovery_payload.discovery_data[ATTR_DISCOVERY_TOPIC])
|
||||
!= self._discovery_data[ATTR_DISCOVERY_TOPIC]
|
||||
):
|
||||
# Make sure the migrated discovery topic is removed.
|
||||
self._migrate_discovery = discovery_topic
|
||||
_LOGGER.debug("Migrating component: %s", discovery_hash)
|
||||
self.hass.async_create_task(
|
||||
async_remove_discovery_payload(self.hass, self._discovery_data)
|
||||
)
|
||||
if (
|
||||
discovery_payload
|
||||
and discovery_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]
|
||||
@@ -835,7 +816,6 @@ class MqttDiscoveryUpdateMixin(Entity):
|
||||
mqtt_data = hass.data[DATA_MQTT]
|
||||
self._registry_hooks = mqtt_data.discovery_registry_hooks
|
||||
discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH]
|
||||
self._migrate_discovery: str | None = None
|
||||
if discovery_hash in self._registry_hooks:
|
||||
self._registry_hooks.pop(discovery_hash)()
|
||||
|
||||
@@ -918,27 +898,12 @@ class MqttDiscoveryUpdateMixin(Entity):
|
||||
old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD]
|
||||
debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id)
|
||||
if not payload:
|
||||
if self._migrate_discovery is not None:
|
||||
# Ignore empty update of the migrated and removed discovery config.
|
||||
self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery
|
||||
self._migrate_discovery = None
|
||||
_LOGGER.info("Component successfully migrated: %s", self.entity_id)
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
return
|
||||
# Empty payload: Remove component
|
||||
_LOGGER.info("Removing component: %s", self.entity_id)
|
||||
self.hass.async_create_task(
|
||||
self._async_process_discovery_update_and_remove()
|
||||
)
|
||||
elif self._discovery_update:
|
||||
discovery_topic = payload.discovery_data[ATTR_DISCOVERY_TOPIC]
|
||||
if discovery_topic != self._discovery_data[ATTR_DISCOVERY_TOPIC]:
|
||||
# Make sure the migrated discovery topic is removed.
|
||||
self._migrate_discovery = discovery_topic
|
||||
_LOGGER.debug("Migrating component: %s", self.entity_id)
|
||||
self.hass.async_create_task(
|
||||
async_remove_discovery_payload(self.hass, self._discovery_data)
|
||||
)
|
||||
if old_payload != payload:
|
||||
# Non-empty, changed payload: Notify component
|
||||
_LOGGER.info("Updating component: %s", self.entity_id)
|
||||
|
||||
@@ -424,15 +424,5 @@ class MqttData:
|
||||
tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MqttComponentConfig:
|
||||
"""(component, object_id, node_id, discovery_payload)."""
|
||||
|
||||
component: str
|
||||
object_id: str
|
||||
node_id: str | None
|
||||
discovery_payload: MQTTDiscoveryPayload
|
||||
|
||||
|
||||
DATA_MQTT: HassKey[MqttData] = HassKey("mqtt")
|
||||
DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -12,7 +10,6 @@ from homeassistant.const import (
|
||||
CONF_ICON,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
@@ -27,13 +24,10 @@ from .const import (
|
||||
CONF_AVAILABILITY_MODE,
|
||||
CONF_AVAILABILITY_TEMPLATE,
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_COMPONENTS,
|
||||
CONF_CONFIGURATION_URL,
|
||||
CONF_CONNECTIONS,
|
||||
CONF_DEPRECATED_VIA_HUB,
|
||||
CONF_ENABLED_BY_DEFAULT,
|
||||
CONF_ENCODING,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
@@ -43,9 +37,7 @@ from .const import (
|
||||
CONF_ORIGIN,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS,
|
||||
CONF_SERIAL_NUMBER,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUGGESTED_AREA,
|
||||
CONF_SUPPORT_URL,
|
||||
CONF_SW_VERSION,
|
||||
@@ -53,33 +45,8 @@ from .const import (
|
||||
CONF_VIA_DEVICE,
|
||||
DEFAULT_PAYLOAD_AVAILABLE,
|
||||
DEFAULT_PAYLOAD_NOT_AVAILABLE,
|
||||
SUPPORTED_COMPONENTS,
|
||||
)
|
||||
from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Device discovery options that are also available at entity component level
|
||||
SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY,
|
||||
CONF_AVAILABILITY_MODE,
|
||||
CONF_AVAILABILITY_TEMPLATE,
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
]
|
||||
|
||||
MQTT_ORIGIN_INFO_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_SW_VERSION): cv.string,
|
||||
vol.Optional(CONF_SUPPORT_URL): cv.configuration_url,
|
||||
}
|
||||
),
|
||||
)
|
||||
from .util import valid_subscribe_topic
|
||||
|
||||
MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -181,19 +148,3 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
COMPONENT_CONFIG_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_PLATFORM): vol.In(SUPPORTED_COMPONENTS)}
|
||||
).extend({}, extra=True)
|
||||
|
||||
DEVICE_DISCOVERY_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
vol.Required(CONF_COMPONENTS): vol.Schema({str: COMPONENT_CONFIG_SCHEMA}),
|
||||
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
|
||||
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -237,28 +237,32 @@ class MqttSensor(MqttEntity, RestoreSensor):
|
||||
payload = msg.payload
|
||||
if payload is PayloadSentinel.DEFAULT:
|
||||
return
|
||||
new_value = str(payload)
|
||||
if not isinstance(payload, str):
|
||||
_LOGGER.warning(
|
||||
"Invalid undecoded state message '%s' received from '%s'",
|
||||
payload,
|
||||
msg.topic,
|
||||
)
|
||||
return
|
||||
if self._numeric_state_expected:
|
||||
if new_value == "":
|
||||
if payload == "":
|
||||
_LOGGER.debug("Ignore empty state from '%s'", msg.topic)
|
||||
elif new_value == PAYLOAD_NONE:
|
||||
elif payload == PAYLOAD_NONE:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
self._attr_native_value = new_value
|
||||
self._attr_native_value = payload
|
||||
return
|
||||
if self.device_class in {
|
||||
None,
|
||||
SensorDeviceClass.ENUM,
|
||||
} and not check_state_too_long(_LOGGER, new_value, self.entity_id, msg):
|
||||
self._attr_native_value = new_value
|
||||
} and not check_state_too_long(_LOGGER, payload, self.entity_id, msg):
|
||||
self._attr_native_value = payload
|
||||
return
|
||||
try:
|
||||
if (payload_datetime := dt_util.parse_datetime(new_value)) is None:
|
||||
if (payload_datetime := dt_util.parse_datetime(payload)) is None:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Invalid state message '%s' from '%s'", msg.payload, msg.topic
|
||||
)
|
||||
_LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic)
|
||||
self._attr_native_value = None
|
||||
return
|
||||
if self.device_class == SensorDeviceClass.DATE:
|
||||
|
||||
@@ -160,6 +160,11 @@ async def async_setup_entry(
|
||||
if find_matching_platform(device_point) == Platform.SENSOR:
|
||||
description = get_description(device_point)
|
||||
entity_class = MyUplinkDevicePointSensor
|
||||
# Ignore sensors without a description that provide non-numeric values
|
||||
if description is None and not isinstance(
|
||||
device_point.value, (int, float)
|
||||
):
|
||||
continue
|
||||
if (
|
||||
description is not None
|
||||
and description.device_class == SensorDeviceClass.ENUM
|
||||
|
||||
@@ -12,9 +12,31 @@ from .const import DOMAIN
|
||||
|
||||
@callback
|
||||
def migrate_notify_issue(
|
||||
hass: HomeAssistant, domain: str, integration_title: str, breaks_in_ha_version: str
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
integration_title: str,
|
||||
breaks_in_ha_version: str,
|
||||
service_name: str | None = None,
|
||||
) -> None:
|
||||
"""Ensure an issue is registered."""
|
||||
if service_name is not None:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"migrate_notify_{domain}_{service_name}",
|
||||
breaks_in_ha_version=breaks_in_ha_version,
|
||||
issue_domain=domain,
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
translation_key="migrate_notify_service",
|
||||
translation_placeholders={
|
||||
"domain": domain,
|
||||
"integration_title": integration_title,
|
||||
"service_name": service_name,
|
||||
},
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
)
|
||||
return
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -72,6 +72,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migrate_notify_service": {
|
||||
"title": "Legacy service `notify.{service_name}` stll being used",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "The {integration_title} `notify.{service_name}` service is migrated, but it seems the old `notify` service is still being used.\n\nA new `notify` entity is available now to replace each legacy `notify` service.\n\nUpdate any automations or scripts to use the new `notify.send_message` service exposed with this new entity. When this is done, select Submit and restart Home Assistant.",
|
||||
"title": "Migrate legacy {integration_title} notify service for domain `{domain}`"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
import openai
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -13,7 +15,11 @@ from homeassistant.core import (
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
issue_registry as ir,
|
||||
@@ -27,13 +33,25 @@ SERVICE_GENERATE_IMAGE = "generate_image"
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up OpenAI Conversation."""
|
||||
|
||||
async def render_image(call: ServiceCall) -> ServiceResponse:
|
||||
"""Render an image with dall-e."""
|
||||
client = hass.data[DOMAIN][call.data["config_entry"]]
|
||||
entry_id = call.data["config_entry"]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
|
||||
if entry is None or entry.domain != DOMAIN:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_config_entry",
|
||||
translation_placeholders={"config_entry": entry_id},
|
||||
)
|
||||
|
||||
client: openai.AsyncClient = entry.runtime_data
|
||||
|
||||
if call.data["size"] in ("256", "512", "1024"):
|
||||
ir.async_create_issue(
|
||||
@@ -51,6 +69,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
else:
|
||||
size = call.data["size"]
|
||||
|
||||
size = cast(
|
||||
Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"],
|
||||
size,
|
||||
) # size is selector, so no need to check further
|
||||
|
||||
try:
|
||||
response = await client.images.generate(
|
||||
model="dall-e-3",
|
||||
@@ -90,7 +113,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
|
||||
"""Set up OpenAI Conversation from a config entry."""
|
||||
client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY])
|
||||
try:
|
||||
@@ -101,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except openai.OpenAIError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
|
||||
entry.runtime_data = client
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -110,8 +133,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload OpenAI."""
|
||||
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return True
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
"""Conversation support for OpenAI."""
|
||||
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
import openai
|
||||
from openai._types import NOT_GIVEN
|
||||
from openai.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCallParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionToolParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
)
|
||||
from openai.types.chat.chat_completion_message_tool_call_param import Function
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.components.conversation import trace
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
@@ -17,6 +29,7 @@ from homeassistant.helpers import device_registry as dr, intent, llm, template
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
@@ -37,7 +50,7 @@ MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: OpenAIConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up conversation entities."""
|
||||
@@ -45,13 +58,12 @@ async def async_setup_entry(
|
||||
async_add_entities([agent])
|
||||
|
||||
|
||||
def _format_tool(tool: llm.Tool) -> dict[str, Any]:
|
||||
def _format_tool(tool: llm.Tool) -> ChatCompletionToolParam:
|
||||
"""Format tool specification."""
|
||||
tool_spec = {"name": tool.name}
|
||||
tool_spec = FunctionDefinition(name=tool.name, parameters=convert(tool.parameters))
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
tool_spec["parameters"] = convert(tool.parameters)
|
||||
return {"type": "function", "function": tool_spec}
|
||||
return ChatCompletionToolParam(type="function", function=tool_spec)
|
||||
|
||||
|
||||
class OpenAIConversationEntity(
|
||||
@@ -62,10 +74,10 @@ class OpenAIConversationEntity(
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
def __init__(self, entry: OpenAIConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.history: dict[str, list[dict]] = {}
|
||||
self.history: dict[str, list[ChatCompletionMessageParam]] = {}
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
@@ -100,21 +112,23 @@ class OpenAIConversationEntity(
|
||||
options = self.entry.options
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
llm_api: llm.APIInstance | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
tools: list[ChatCompletionToolParam] | None = None
|
||||
user_name: str | None = None
|
||||
llm_context = llm.LLMContext(
|
||||
platform=DOMAIN,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=conversation.DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
if options.get(CONF_LLM_HASS_API):
|
||||
try:
|
||||
llm_api = await llm.async_get_api(
|
||||
self.hass,
|
||||
options[CONF_LLM_HASS_API],
|
||||
llm.ToolContext(
|
||||
platform=DOMAIN,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=conversation.DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
),
|
||||
llm_context,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.error("Error getting LLM API: %s", err)
|
||||
@@ -132,48 +146,65 @@ class OpenAIConversationEntity(
|
||||
messages = self.history[conversation_id]
|
||||
else:
|
||||
conversation_id = ulid.ulid_now()
|
||||
try:
|
||||
if llm_api:
|
||||
api_prompt = llm_api.api_prompt
|
||||
else:
|
||||
api_prompt = llm.async_render_no_api_prompt(self.hass)
|
||||
messages = []
|
||||
|
||||
prompt = "\n".join(
|
||||
(
|
||||
template.Template(
|
||||
options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
},
|
||||
parse_result=False,
|
||||
),
|
||||
api_prompt,
|
||||
)
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
||||
)
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
if llm_api:
|
||||
api_prompt = llm_api.api_prompt
|
||||
else:
|
||||
api_prompt = llm.async_render_no_api_prompt(self.hass)
|
||||
|
||||
prompt = "\n".join(
|
||||
(
|
||||
template.Template(
|
||||
llm.BASE_PROMPT
|
||||
+ options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
),
|
||||
api_prompt,
|
||||
)
|
||||
)
|
||||
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
f"Sorry, I had a problem with my template: {err}",
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
f"Sorry, I had a problem with my template: {err}",
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": prompt}]
|
||||
|
||||
messages.append({"role": "user", "content": user_input.text})
|
||||
# Create a copy of the variable because we attach it to the trace
|
||||
messages = [
|
||||
ChatCompletionSystemMessageParam(role="system", content=prompt),
|
||||
*messages[1:],
|
||||
ChatCompletionUserMessageParam(role="user", content=user_input.text),
|
||||
]
|
||||
|
||||
LOGGER.debug("Prompt: %s", messages)
|
||||
trace.async_conversation_trace_append(
|
||||
trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages}
|
||||
)
|
||||
|
||||
client = self.hass.data[DOMAIN][self.entry.entry_id]
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
@@ -181,7 +212,7 @@ class OpenAIConversationEntity(
|
||||
result = await client.chat.completions.create(
|
||||
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
messages=messages,
|
||||
tools=tools or None,
|
||||
tools=tools or NOT_GIVEN,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
@@ -199,7 +230,33 @@ class OpenAIConversationEntity(
|
||||
|
||||
LOGGER.debug("Response %s", result)
|
||||
response = result.choices[0].message
|
||||
messages.append(response)
|
||||
|
||||
def message_convert(
|
||||
message: ChatCompletionMessage,
|
||||
) -> ChatCompletionMessageParam:
|
||||
"""Convert from class to TypedDict."""
|
||||
tool_calls: list[ChatCompletionMessageToolCallParam] = []
|
||||
if message.tool_calls:
|
||||
tool_calls = [
|
||||
ChatCompletionMessageToolCallParam(
|
||||
id=tool_call.id,
|
||||
function=Function(
|
||||
arguments=tool_call.function.arguments,
|
||||
name=tool_call.function.name,
|
||||
),
|
||||
type=tool_call.type,
|
||||
)
|
||||
for tool_call in message.tool_calls
|
||||
]
|
||||
param = ChatCompletionAssistantMessageParam(
|
||||
role=message.role,
|
||||
content=message.content,
|
||||
)
|
||||
if tool_calls:
|
||||
param["tool_calls"] = tool_calls
|
||||
return param
|
||||
|
||||
messages.append(message_convert(response))
|
||||
tool_calls = response.tool_calls
|
||||
|
||||
if not tool_calls or not llm_api:
|
||||
@@ -223,18 +280,17 @@ class OpenAIConversationEntity(
|
||||
|
||||
LOGGER.debug("Tool response: %s", tool_response)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"name": tool_call.function.name,
|
||||
"content": json.dumps(tool_response),
|
||||
}
|
||||
ChatCompletionToolMessageParam(
|
||||
role="tool",
|
||||
tool_call_id=tool_call.id,
|
||||
content=json.dumps(tool_response),
|
||||
)
|
||||
)
|
||||
|
||||
self.history[conversation_id] = messages
|
||||
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_speech(response.content)
|
||||
intent_response.async_set_speech(response.content or "")
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_config_entry": {
|
||||
"message": "Invalid config entry provided. Got {config_entry}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"image_size_deprecated_format": {
|
||||
"title": "Deprecated size format for image generation service",
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyopenweathermap import OWMClient
|
||||
|
||||
@@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant
|
||||
from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
from .repairs import async_create_issue, async_delete_issue
|
||||
from .utils import build_data_and_options
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,8 +44,8 @@ async def async_setup_entry(
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude)
|
||||
longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude)
|
||||
language = _get_config_value(entry, CONF_LANGUAGE)
|
||||
mode = _get_config_value(entry, CONF_MODE)
|
||||
language = entry.options[CONF_LANGUAGE]
|
||||
mode = entry.options[CONF_MODE]
|
||||
|
||||
if mode == OWM_MODE_V25:
|
||||
async_create_issue(hass, entry.entry_id)
|
||||
@@ -77,10 +77,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
_LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version)
|
||||
|
||||
if version < 4:
|
||||
new_data = {**data, **options, CONF_MODE: OWM_MODE_V25}
|
||||
if version < 5:
|
||||
combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25}
|
||||
new_data, new_options = build_data_and_options(combined_data)
|
||||
config_entries.async_update_entry(
|
||||
entry, data=new_data, options={}, version=CONFIG_FLOW_VERSION
|
||||
entry,
|
||||
data=new_data,
|
||||
options=new_options,
|
||||
version=CONFIG_FLOW_VERSION,
|
||||
)
|
||||
|
||||
_LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION)
|
||||
@@ -98,9 +102,3 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def _get_config_value(config_entry: ConfigEntry, key: str) -> Any:
|
||||
if config_entry.options and key in config_entry.options:
|
||||
return config_entry.options[key]
|
||||
return config_entry.data[key]
|
||||
|
||||
@@ -30,7 +30,7 @@ from .const import (
|
||||
LANGUAGES,
|
||||
OWM_MODES,
|
||||
)
|
||||
from .utils import validate_api_key
|
||||
from .utils import build_data_and_options, validate_api_key
|
||||
|
||||
|
||||
class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -64,8 +64,9 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if not errors:
|
||||
data, options = build_data_and_options(user_input)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input
|
||||
title=user_input[CONF_NAME], data=data, options=options
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
|
||||
@@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap"
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
ATTRIBUTION = "Data provided by OpenWeatherMap"
|
||||
MANUFACTURER = "OpenWeather"
|
||||
CONFIG_FLOW_VERSION = 4
|
||||
CONFIG_FLOW_VERSION = 5
|
||||
ATTR_API_PRECIPITATION = "precipitation"
|
||||
ATTR_API_PRECIPITATION_KIND = "precipitation_kind"
|
||||
ATTR_API_DATETIME = "datetime"
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"step": {
|
||||
"migrate": {
|
||||
"title": "OpenWeatherMap API V2.5 deprecated",
|
||||
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information."
|
||||
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integrations to v3.0.\n\nBefore the migration, you must have an active subscription (be aware that subscription activation can take up to 2h). After your subscription is activated, select **Submit** to migrate the integration to API V3.0. Read the documentation for more information."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""Util functions for OpenWeatherMap."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyopenweathermap import OWMClient, RequestError
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
|
||||
|
||||
from .const import DEFAULT_LANGUAGE, DEFAULT_OWM_MODE
|
||||
|
||||
OPTION_DEFAULTS = {CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE}
|
||||
|
||||
|
||||
async def validate_api_key(api_key, mode):
|
||||
"""Validate API key."""
|
||||
@@ -18,3 +26,15 @@ async def validate_api_key(api_key, mode):
|
||||
errors["base"] = "invalid_api_key"
|
||||
|
||||
return errors, description_placeholders
|
||||
|
||||
|
||||
def build_data_and_options(
|
||||
combined_data: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Split combined data and options."""
|
||||
data = {k: v for k, v in combined_data.items() if k not in OPTION_DEFAULTS}
|
||||
options = {
|
||||
option: combined_data.get(option, default)
|
||||
for option, default in OPTION_DEFAULTS.items()
|
||||
}
|
||||
return (data, options)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.13.10"],
|
||||
"requirements": ["pyoverkiz==1.13.11"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
|
||||
from radios import FilterBy, Order, RadioBrowser, Station
|
||||
from radios.radio_browser import pycountry
|
||||
|
||||
from homeassistant.components.media_player import MediaClass, MediaType
|
||||
from homeassistant.components.media_source.error import Unresolvable
|
||||
@@ -145,6 +146,8 @@ class RadioMediaSource(MediaSource):
|
||||
|
||||
# We show country in the root additionally, when there is no item
|
||||
if not item.identifier or category == "country":
|
||||
# Trigger the lazy loading of the country database to happen inside the executor
|
||||
await self.hass.async_add_executor_job(lambda: len(pycountry.countries))
|
||||
countries = await radios.countries(order=Order.NAME)
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as dt
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -44,11 +44,7 @@ from .statistics import (
|
||||
statistics_during_period,
|
||||
validate_statistics,
|
||||
)
|
||||
from .util import PERIOD_SCHEMA, get_instance, resolve_period, session_scope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import Recorder
|
||||
|
||||
from .util import PERIOD_SCHEMA, get_instance, resolve_period
|
||||
|
||||
UNIT_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -85,7 +81,6 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_info)
|
||||
websocket_api.async_register_command(hass, ws_update_statistics_metadata)
|
||||
websocket_api.async_register_command(hass, ws_validate_statistics)
|
||||
websocket_api.async_register_command(hass, ws_get_recorded_entities)
|
||||
|
||||
|
||||
def _ws_get_statistic_during_period(
|
||||
@@ -518,40 +513,3 @@ def ws_info(
|
||||
"thread_running": is_running,
|
||||
}
|
||||
connection.send_result(msg["id"], recorder_info)
|
||||
|
||||
|
||||
def _get_recorded_entities(
|
||||
hass: HomeAssistant, msg_id: int, instance: Recorder
|
||||
) -> bytes:
|
||||
"""Get the list of entities being recorded."""
|
||||
with session_scope(hass=hass, read_only=True) as session:
|
||||
return json_bytes(
|
||||
messages.result_message(
|
||||
msg_id,
|
||||
{
|
||||
"entity_ids": list(
|
||||
instance.states_meta_manager.get_metadata_id_to_entity_id(
|
||||
session
|
||||
).values()
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "recorder/recorded_entities",
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_recorded_entities(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Get the list of entities being recorded."""
|
||||
instance = get_instance(hass)
|
||||
return connection.send_message(
|
||||
await instance.async_add_executor_job(
|
||||
_get_recorded_entities, hass, msg["id"], instance
|
||||
)
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple(
|
||||
key="hvac_status",
|
||||
coordinator="hvac_status",
|
||||
on_key="hvacStatus",
|
||||
on_value="on",
|
||||
on_value=2,
|
||||
translation_key="hvac_status",
|
||||
),
|
||||
RenaultBinarySensorEntityDescription(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["renault-api==0.2.2"]
|
||||
"requirements": ["renault-api==0.2.3"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import CONF_USE_HTTPS, DOMAIN
|
||||
@@ -60,7 +60,24 @@ class ReolinkOptionsFlowHandler(OptionsFlow):
|
||||
vol.Required(
|
||||
CONF_PROTOCOL,
|
||||
default=self.config_entry.options[CONF_PROTOCOL],
|
||||
): vol.In(["rtsp", "rtmp", "flv"]),
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(
|
||||
value="rtsp",
|
||||
label="RTSP",
|
||||
),
|
||||
selector.SelectOptionDict(
|
||||
value="rtmp",
|
||||
label="RTMP",
|
||||
),
|
||||
selector.SelectOptionDict(
|
||||
value="flv",
|
||||
label="FLV",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -89,11 +89,22 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Entity created."""
|
||||
await super().async_added_to_hass()
|
||||
if (
|
||||
self.entity_description.cmd_key is not None
|
||||
and self.entity_description.cmd_key not in self._host.update_cmd_list
|
||||
):
|
||||
self._host.update_cmd_list.append(self.entity_description.cmd_key)
|
||||
cmd_key = self.entity_description.cmd_key
|
||||
if cmd_key is not None:
|
||||
self._host.async_register_update_cmd(cmd_key)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity removed."""
|
||||
cmd_key = self.entity_description.cmd_key
|
||||
if cmd_key is not None:
|
||||
self._host.async_unregister_update_cmd(cmd_key)
|
||||
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Force full update from the generic entity update service."""
|
||||
self._host.last_wake = 0
|
||||
await super().async_update()
|
||||
|
||||
|
||||
class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
|
||||
@@ -128,3 +139,18 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
|
||||
sw_version=self._host.api.camera_sw_version(dev_ch),
|
||||
configuration_url=self._conf_url,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Entity created."""
|
||||
await super().async_added_to_hass()
|
||||
cmd_key = self.entity_description.cmd_key
|
||||
if cmd_key is not None:
|
||||
self._host.async_register_update_cmd(cmd_key, self._channel)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity removed."""
|
||||
cmd_key = self.entity_description.cmd_key
|
||||
if cmd_key is not None:
|
||||
self._host.async_unregister_update_cmd(cmd_key, self._channel)
|
||||
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from time import time
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiohttp
|
||||
@@ -21,7 +23,7 @@ from homeassistant.const import (
|
||||
CONF_PROTOCOL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -39,6 +41,10 @@ POLL_INTERVAL_NO_PUSH = 5
|
||||
LONG_POLL_COOLDOWN = 0.75
|
||||
LONG_POLL_ERROR_COOLDOWN = 30
|
||||
|
||||
# Conserve battery by not waking the battery cameras each minute during normal update
|
||||
# Most props are cached in the Home Hub and updated, but some are skipped
|
||||
BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -67,7 +73,10 @@ class ReolinkHost:
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
self.update_cmd_list: list[str] = []
|
||||
self.last_wake: float = 0
|
||||
self._update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict(
|
||||
lambda: defaultdict(int)
|
||||
)
|
||||
|
||||
self.webhook_id: str | None = None
|
||||
self._onvif_push_supported: bool = True
|
||||
@@ -84,6 +93,20 @@ class ReolinkHost:
|
||||
self._long_poll_task: asyncio.Task | None = None
|
||||
self._lost_subscription: bool = False
|
||||
|
||||
@callback
|
||||
def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None:
|
||||
"""Register the command to update the state."""
|
||||
self._update_cmd[cmd][channel] += 1
|
||||
|
||||
@callback
|
||||
def async_unregister_update_cmd(self, cmd: str, channel: int | None = None) -> None:
|
||||
"""Unregister the command to update the state."""
|
||||
self._update_cmd[cmd][channel] -= 1
|
||||
if not self._update_cmd[cmd][channel]:
|
||||
del self._update_cmd[cmd][channel]
|
||||
if not self._update_cmd[cmd]:
|
||||
del self._update_cmd[cmd]
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Create the unique ID, base for all entities."""
|
||||
@@ -320,7 +343,13 @@ class ReolinkHost:
|
||||
|
||||
async def update_states(self) -> None:
|
||||
"""Call the API of the camera device to update the internal states."""
|
||||
await self._api.get_states(cmd_list=self.update_cmd_list)
|
||||
wake = False
|
||||
if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL:
|
||||
# wake the battery cameras for a complete update
|
||||
wake = True
|
||||
self.last_wake = time()
|
||||
|
||||
await self._api.get_states(cmd_list=self._update_cmd, wake=wake)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the API, so the connection will be released."""
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.8.11"]
|
||||
"requirements": ["reolink-aio==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -109,12 +109,14 @@ SELECT_ENTITIES = (
|
||||
ReolinkSelectEntityDescription(
|
||||
key="status_led",
|
||||
cmd_key="GetPowerLed",
|
||||
translation_key="status_led",
|
||||
translation_key="doorbell_led",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
get_options=[state.name for state in StatusLedEnum],
|
||||
get_options=lambda api, ch: api.doorbell_led_list(ch),
|
||||
supported=lambda api, ch: api.supported(ch, "doorbell_led"),
|
||||
value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name,
|
||||
method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value),
|
||||
method=lambda api, ch, name: (
|
||||
api.set_status_led(ch, StatusLedEnum[name].value, doorbell=True)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -383,8 +383,8 @@
|
||||
"pantiltfirst": "Pan/tilt first"
|
||||
}
|
||||
},
|
||||
"status_led": {
|
||||
"name": "Status LED",
|
||||
"doorbell_led": {
|
||||
"name": "Doorbell LED",
|
||||
"state": {
|
||||
"stayoff": "Stay off",
|
||||
"auto": "Auto",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["roborock"],
|
||||
"requirements": [
|
||||
"python-roborock==2.1.1",
|
||||
"python-roborock==2.2.3",
|
||||
"vacuum-map-parser-roborock==0.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -301,9 +301,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
dev_reg, config_entry.entry_id
|
||||
):
|
||||
for connection in device.connections:
|
||||
if connection == (dr.CONNECTION_NETWORK_MAC, "none"):
|
||||
dev_reg.async_remove_device(device.id)
|
||||
new_connections = device.connections.copy()
|
||||
new_connections.discard((dr.CONNECTION_NETWORK_MAC, "none"))
|
||||
if new_connections != device.connections:
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_connections=new_connections
|
||||
)
|
||||
|
||||
minor_version = 2
|
||||
hass.config_entries.async_update_entry(config_entry, minor_version=2)
|
||||
|
||||
@@ -325,6 +325,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
|
||||
"""Try to gather infos of this device."""
|
||||
return None
|
||||
|
||||
def _notify_reauth_callback(self) -> None:
|
||||
"""Notify access denied callback."""
|
||||
if self._reauth_callback is not None:
|
||||
self.hass.loop.call_soon_threadsafe(self._reauth_callback)
|
||||
|
||||
def _get_remote(self) -> Remote:
|
||||
"""Create or return a remote control instance."""
|
||||
if self._remote is None:
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform, inst
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.loader import Integration, async_get_custom_components
|
||||
from homeassistant.setup import SetupPhases, async_pause_setup
|
||||
|
||||
from .const import (
|
||||
CONF_DSN,
|
||||
@@ -41,7 +42,6 @@ from .const import (
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
|
||||
LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$")
|
||||
|
||||
|
||||
@@ -81,23 +81,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
),
|
||||
}
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=entry.data[CONF_DSN],
|
||||
environment=entry.options.get(CONF_ENVIRONMENT),
|
||||
integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()],
|
||||
release=current_version,
|
||||
before_send=lambda event, hint: process_before_send(
|
||||
hass,
|
||||
entry.options,
|
||||
channel,
|
||||
huuid,
|
||||
system_info,
|
||||
custom_components,
|
||||
event,
|
||||
hint,
|
||||
),
|
||||
**tracing,
|
||||
)
|
||||
with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
|
||||
# sentry_sdk.init imports modules based on the selected integrations
|
||||
def _init_sdk():
|
||||
"""Initialize the Sentry SDK."""
|
||||
sentry_sdk.init(
|
||||
dsn=entry.data[CONF_DSN],
|
||||
environment=entry.options.get(CONF_ENVIRONMENT),
|
||||
integrations=[
|
||||
sentry_logging,
|
||||
AioHttpIntegration(),
|
||||
SqlalchemyIntegration(),
|
||||
],
|
||||
release=current_version,
|
||||
before_send=lambda event, hint: process_before_send(
|
||||
hass,
|
||||
entry.options,
|
||||
channel,
|
||||
huuid,
|
||||
system_info,
|
||||
custom_components,
|
||||
event,
|
||||
hint,
|
||||
),
|
||||
**tracing,
|
||||
)
|
||||
|
||||
await hass.async_add_import_executor_job(_init_sdk)
|
||||
|
||||
async def update_system_info(now):
|
||||
nonlocal system_info
|
||||
|
||||
@@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except RequestError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = SENZDataUpdateCoordinator(
|
||||
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=account.username,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_room": {
|
||||
"message": "The room { room } is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization."
|
||||
"message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -737,7 +737,8 @@ def get_block_coordinator_by_device_id(
|
||||
entry = hass.config_entries.async_get_entry(config_entry)
|
||||
if (
|
||||
entry
|
||||
and entry.state == ConfigEntryState.LOADED
|
||||
and entry.state is ConfigEntryState.LOADED
|
||||
and hasattr(entry, "runtime_data")
|
||||
and isinstance(entry.runtime_data, ShellyEntryData)
|
||||
and (coordinator := entry.runtime_data.block)
|
||||
):
|
||||
@@ -756,7 +757,8 @@ def get_rpc_coordinator_by_device_id(
|
||||
entry = hass.config_entries.async_get_entry(config_entry)
|
||||
if (
|
||||
entry
|
||||
and entry.state == ConfigEntryState.LOADED
|
||||
and entry.state is ConfigEntryState.LOADED
|
||||
and hasattr(entry, "runtime_data")
|
||||
and isinstance(entry.runtime_data, ShellyEntryData)
|
||||
and (coordinator := entry.runtime_data.rpc)
|
||||
):
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
"""The snmp component."""
|
||||
|
||||
from .util import async_get_snmp_engine
|
||||
|
||||
__all__ = ["async_get_snmp_engine"]
|
||||
|
||||
@@ -4,14 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pysnmp.error import PySnmpError
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
CommunityData,
|
||||
ContextData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
SnmpEngine,
|
||||
Udp6TransportTarget,
|
||||
UdpTransportTarget,
|
||||
UsmUserData,
|
||||
@@ -43,6 +40,7 @@ from .const import (
|
||||
DEFAULT_VERSION,
|
||||
SNMP_VERSIONS,
|
||||
)
|
||||
from .util import RequestArgsType, async_create_request_cmd_args
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,7 +60,7 @@ async def async_get_scanner(
|
||||
) -> SnmpScanner | None:
|
||||
"""Validate the configuration and return an SNMP scanner."""
|
||||
scanner = SnmpScanner(config[DOMAIN])
|
||||
await scanner.async_init()
|
||||
await scanner.async_init(hass)
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
@@ -99,33 +97,29 @@ class SnmpScanner(DeviceScanner):
|
||||
if not privkey:
|
||||
privproto = "none"
|
||||
|
||||
request_args = [
|
||||
SnmpEngine(),
|
||||
UsmUserData(
|
||||
community,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=authproto,
|
||||
privProtocol=privproto,
|
||||
),
|
||||
target,
|
||||
ContextData(),
|
||||
]
|
||||
self._auth_data = UsmUserData(
|
||||
community,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=authproto,
|
||||
privProtocol=privproto,
|
||||
)
|
||||
else:
|
||||
request_args = [
|
||||
SnmpEngine(),
|
||||
CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]),
|
||||
target,
|
||||
ContextData(),
|
||||
]
|
||||
self._auth_data = CommunityData(
|
||||
community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]
|
||||
)
|
||||
|
||||
self.request_args = request_args
|
||||
self._target = target
|
||||
self.request_args: RequestArgsType | None = None
|
||||
self.baseoid = baseoid
|
||||
self.last_results = []
|
||||
self.success_init = False
|
||||
|
||||
async def async_init(self):
|
||||
async def async_init(self, hass: HomeAssistant) -> None:
|
||||
"""Make a one-off read to check if the target device is reachable and readable."""
|
||||
self.request_args = await async_create_request_cmd_args(
|
||||
hass, self._auth_data, self._target, self.baseoid
|
||||
)
|
||||
data = await self.async_get_snmp_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
@@ -156,12 +150,18 @@ class SnmpScanner(DeviceScanner):
|
||||
async def async_get_snmp_data(self):
|
||||
"""Fetch MAC addresses from access point via SNMP."""
|
||||
devices = []
|
||||
if TYPE_CHECKING:
|
||||
assert self.request_args is not None
|
||||
|
||||
engine, auth_data, target, context_data, object_type = self.request_args
|
||||
walker = bulkWalkCmd(
|
||||
*self.request_args,
|
||||
engine,
|
||||
auth_data,
|
||||
target,
|
||||
context_data,
|
||||
0,
|
||||
50,
|
||||
ObjectType(ObjectIdentity(self.baseoid)),
|
||||
object_type,
|
||||
lexicographicMode=False,
|
||||
)
|
||||
async for errindication, errstatus, errindex, res in walker:
|
||||
|
||||
@@ -11,10 +11,6 @@ from pysnmp.error import PySnmpError
|
||||
import pysnmp.hlapi.asyncio as hlapi
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
CommunityData,
|
||||
ContextData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
SnmpEngine,
|
||||
Udp6TransportTarget,
|
||||
UdpTransportTarget,
|
||||
UsmUserData,
|
||||
@@ -71,6 +67,7 @@ from .const import (
|
||||
MAP_PRIV_PROTOCOLS,
|
||||
SNMP_VERSIONS,
|
||||
)
|
||||
from .util import async_create_request_cmd_args
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,7 +116,7 @@ async def async_setup_platform(
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
community = config.get(CONF_COMMUNITY)
|
||||
baseoid = config.get(CONF_BASEOID)
|
||||
baseoid: str = config[CONF_BASEOID]
|
||||
version = config[CONF_VERSION]
|
||||
username = config.get(CONF_USERNAME)
|
||||
authkey = config.get(CONF_AUTH_KEY)
|
||||
@@ -145,27 +142,18 @@ async def async_setup_platform(
|
||||
authproto = "none"
|
||||
if not privkey:
|
||||
privproto = "none"
|
||||
|
||||
request_args = [
|
||||
SnmpEngine(),
|
||||
UsmUserData(
|
||||
username,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
|
||||
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
|
||||
),
|
||||
target,
|
||||
ContextData(),
|
||||
]
|
||||
auth_data = UsmUserData(
|
||||
username,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
|
||||
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
|
||||
)
|
||||
else:
|
||||
request_args = [
|
||||
SnmpEngine(),
|
||||
CommunityData(community, mpModel=SNMP_VERSIONS[version]),
|
||||
target,
|
||||
ContextData(),
|
||||
]
|
||||
get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid)))
|
||||
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
|
||||
|
||||
request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid)
|
||||
get_result = await getCmd(*request_args)
|
||||
errindication, _, _, _ = get_result
|
||||
|
||||
if errindication and not accept_errors:
|
||||
@@ -244,9 +232,7 @@ class SnmpData:
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the remote SNMP capable host."""
|
||||
|
||||
get_result = await getCmd(
|
||||
*self._request_args, ObjectType(ObjectIdentity(self._baseoid))
|
||||
)
|
||||
get_result = await getCmd(*self._request_args)
|
||||
errindication, errstatus, errindex, restable = get_result
|
||||
|
||||
if errindication and not self._accept_errors:
|
||||
|
||||
@@ -8,10 +8,6 @@ from typing import Any
|
||||
import pysnmp.hlapi.asyncio as hlapi
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
CommunityData,
|
||||
ContextData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
SnmpEngine,
|
||||
UdpTransportTarget,
|
||||
UsmUserData,
|
||||
getCmd,
|
||||
@@ -67,6 +63,7 @@ from .const import (
|
||||
MAP_PRIV_PROTOCOLS,
|
||||
SNMP_VERSIONS,
|
||||
)
|
||||
from .util import RequestArgsType, async_create_request_cmd_args
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,40 +129,54 @@ async def async_setup_platform(
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
community = config.get(CONF_COMMUNITY)
|
||||
baseoid = config.get(CONF_BASEOID)
|
||||
baseoid: str = config[CONF_BASEOID]
|
||||
command_oid = config.get(CONF_COMMAND_OID)
|
||||
command_payload_on = config.get(CONF_COMMAND_PAYLOAD_ON)
|
||||
command_payload_off = config.get(CONF_COMMAND_PAYLOAD_OFF)
|
||||
version = config.get(CONF_VERSION)
|
||||
version: str = config[CONF_VERSION]
|
||||
username = config.get(CONF_USERNAME)
|
||||
authkey = config.get(CONF_AUTH_KEY)
|
||||
authproto = config.get(CONF_AUTH_PROTOCOL)
|
||||
authproto: str = config[CONF_AUTH_PROTOCOL]
|
||||
privkey = config.get(CONF_PRIV_KEY)
|
||||
privproto = config.get(CONF_PRIV_PROTOCOL)
|
||||
privproto: str = config[CONF_PRIV_PROTOCOL]
|
||||
payload_on = config.get(CONF_PAYLOAD_ON)
|
||||
payload_off = config.get(CONF_PAYLOAD_OFF)
|
||||
vartype = config.get(CONF_VARTYPE)
|
||||
|
||||
if version == "3":
|
||||
if not authkey:
|
||||
authproto = "none"
|
||||
if not privkey:
|
||||
privproto = "none"
|
||||
|
||||
auth_data = UsmUserData(
|
||||
username,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
|
||||
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
|
||||
)
|
||||
else:
|
||||
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
|
||||
|
||||
request_args = await async_create_request_cmd_args(
|
||||
hass, auth_data, UdpTransportTarget((host, port)), baseoid
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SnmpSwitch(
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
community,
|
||||
baseoid,
|
||||
command_oid,
|
||||
version,
|
||||
username,
|
||||
authkey,
|
||||
authproto,
|
||||
privkey,
|
||||
privproto,
|
||||
payload_on,
|
||||
payload_off,
|
||||
command_payload_on,
|
||||
command_payload_off,
|
||||
vartype,
|
||||
request_args,
|
||||
)
|
||||
],
|
||||
True,
|
||||
@@ -180,21 +191,15 @@ class SnmpSwitch(SwitchEntity):
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
community,
|
||||
baseoid,
|
||||
commandoid,
|
||||
version,
|
||||
username,
|
||||
authkey,
|
||||
authproto,
|
||||
privkey,
|
||||
privproto,
|
||||
payload_on,
|
||||
payload_off,
|
||||
command_payload_on,
|
||||
command_payload_off,
|
||||
vartype,
|
||||
):
|
||||
request_args,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
|
||||
self._name = name
|
||||
@@ -206,35 +211,11 @@ class SnmpSwitch(SwitchEntity):
|
||||
self._command_payload_on = command_payload_on or payload_on
|
||||
self._command_payload_off = command_payload_off or payload_off
|
||||
|
||||
self._state = None
|
||||
self._state: bool | None = None
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
|
||||
if version == "3":
|
||||
if not authkey:
|
||||
authproto = "none"
|
||||
if not privkey:
|
||||
privproto = "none"
|
||||
|
||||
self._request_args = [
|
||||
SnmpEngine(),
|
||||
UsmUserData(
|
||||
username,
|
||||
authKey=authkey or None,
|
||||
privKey=privkey or None,
|
||||
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
|
||||
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
|
||||
),
|
||||
UdpTransportTarget((host, port)),
|
||||
ContextData(),
|
||||
]
|
||||
else:
|
||||
self._request_args = [
|
||||
SnmpEngine(),
|
||||
CommunityData(community, mpModel=SNMP_VERSIONS[version]),
|
||||
UdpTransportTarget((host, port)),
|
||||
ContextData(),
|
||||
]
|
||||
self._target = UdpTransportTarget((host, port))
|
||||
self._request_args: RequestArgsType = request_args
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
@@ -259,9 +240,7 @@ class SnmpSwitch(SwitchEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state."""
|
||||
get_result = await getCmd(
|
||||
*self._request_args, ObjectType(ObjectIdentity(self._baseoid))
|
||||
)
|
||||
get_result = await getCmd(*self._request_args)
|
||||
errindication, errstatus, errindex, restable = get_result
|
||||
|
||||
if errindication:
|
||||
@@ -296,6 +275,4 @@ class SnmpSwitch(SwitchEntity):
|
||||
return self._state
|
||||
|
||||
async def _set(self, value):
|
||||
await setCmd(
|
||||
*self._request_args, ObjectType(ObjectIdentity(self._commandoid), value)
|
||||
)
|
||||
await setCmd(*self._request_args, value)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Support for displaying collected data over SNMP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
CommunityData,
|
||||
ContextData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
SnmpEngine,
|
||||
Udp6TransportTarget,
|
||||
UdpTransportTarget,
|
||||
UsmUserData,
|
||||
)
|
||||
from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor
|
||||
from pysnmp.smi.builder import MibBuilder
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
|
||||
DATA_SNMP_ENGINE = "snmp_engine"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type RequestArgsType = tuple[
|
||||
SnmpEngine,
|
||||
UsmUserData | CommunityData,
|
||||
UdpTransportTarget | Udp6TransportTarget,
|
||||
ContextData,
|
||||
ObjectType,
|
||||
]
|
||||
|
||||
|
||||
async def async_create_request_cmd_args(
|
||||
hass: HomeAssistant,
|
||||
auth_data: UsmUserData | CommunityData,
|
||||
target: UdpTransportTarget | Udp6TransportTarget,
|
||||
object_id: str,
|
||||
) -> RequestArgsType:
|
||||
"""Create request arguments."""
|
||||
return (
|
||||
await async_get_snmp_engine(hass),
|
||||
auth_data,
|
||||
target,
|
||||
ContextData(),
|
||||
ObjectType(ObjectIdentity(object_id)),
|
||||
)
|
||||
|
||||
|
||||
@singleton(DATA_SNMP_ENGINE)
|
||||
async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine:
|
||||
"""Get the SNMP engine."""
|
||||
engine = await hass.async_add_executor_job(_get_snmp_engine)
|
||||
|
||||
@callback
|
||||
def _async_shutdown_listener(ev: Event) -> None:
|
||||
_LOGGER.debug("Unconfiguring SNMP engine")
|
||||
lcd.unconfigure(engine, None)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener)
|
||||
return engine
|
||||
|
||||
|
||||
def _get_snmp_engine() -> SnmpEngine:
|
||||
"""Return a cached instance of SnmpEngine."""
|
||||
engine = SnmpEngine()
|
||||
mib_controller = vbProcessor.getMibViewController(engine)
|
||||
# Actually load the MIBs from disk so we do
|
||||
# not do it in the event loop
|
||||
builder: MibBuilder = mib_controller.mibBuilder
|
||||
if "PYSNMP-MIB" not in builder.mibSymbols:
|
||||
builder.loadModules()
|
||||
return engine
|
||||
@@ -119,12 +119,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
|
||||
|
||||
async def async_set_sleep_duration(self, end: int) -> None:
|
||||
"""Set Starlink system sleep schedule end time."""
|
||||
duration = end - self.data.sleep[0]
|
||||
if duration < 0:
|
||||
# If the duration pushed us into the next day, add one days worth to correct that.
|
||||
duration += 1440
|
||||
async with asyncio.timeout(4):
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
set_sleep_config,
|
||||
self.data.sleep[0],
|
||||
end,
|
||||
duration,
|
||||
self.data.sleep[2],
|
||||
self.channel_context,
|
||||
)
|
||||
|
||||
@@ -62,6 +62,8 @@ class StarlinkTimeEntity(StarlinkEntity, TimeEntity):
|
||||
|
||||
def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
hour = math.floor(utc_minutes / 60)
|
||||
if hour > 23:
|
||||
hour -= 24
|
||||
minute = utc_minutes % 60
|
||||
try:
|
||||
utc = datetime.now(UTC).replace(
|
||||
|
||||
@@ -104,6 +104,11 @@ class SynoApi:
|
||||
except BaseException as err:
|
||||
if not self._login_future.done():
|
||||
self._login_future.set_exception(err)
|
||||
with suppress(BaseException):
|
||||
# Clear the flag as its normal that nothing
|
||||
# will wait for this future to be resolved
|
||||
# if there are no concurrent login attempts
|
||||
await self._login_future
|
||||
raise
|
||||
finally:
|
||||
self._login_future = None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
import uuid
|
||||
@@ -9,15 +10,11 @@ import uuid
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_ID, CONF_NAME
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import collection, entity_registry as er
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -34,7 +31,7 @@ LAST_SCANNED = "last_scanned"
|
||||
LAST_SCANNED_BY_DEVICE_ID = "last_scanned_by_device_id"
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 2
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
|
||||
TAG_DATA: HassKey[TagStorageCollection] = HassKey(DOMAIN)
|
||||
SIGNAL_TAG_CHANGED = "signal_tag_changed"
|
||||
@@ -107,8 +104,14 @@ class TagStore(Store[collection.SerializedStorageCollection]):
|
||||
# Version 1.2 moves name to entity registry
|
||||
for tag in data["items"]:
|
||||
# Copy name in tag store to the entity registry
|
||||
_create_entry(entity_registry, tag[TAG_ID], tag.get(CONF_NAME))
|
||||
_create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME))
|
||||
tag["migrated"] = True
|
||||
if old_major_version == 1 and old_minor_version < 3:
|
||||
# Version 1.3 removes tag_id from the store
|
||||
for tag in data["items"]:
|
||||
if TAG_ID not in tag:
|
||||
continue
|
||||
del tag[TAG_ID]
|
||||
|
||||
if old_major_version > 1:
|
||||
raise NotImplementedError
|
||||
@@ -136,24 +139,26 @@ class TagStorageCollection(collection.DictStorageCollection):
|
||||
data = self.CREATE_SCHEMA(data)
|
||||
if not data[TAG_ID]:
|
||||
data[TAG_ID] = str(uuid.uuid4())
|
||||
# Move tag id to id
|
||||
data[CONF_ID] = data.pop(TAG_ID)
|
||||
# make last_scanned JSON serializeable
|
||||
if LAST_SCANNED in data:
|
||||
data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
|
||||
|
||||
# Create entity in entity_registry when creating the tag
|
||||
# This is done early to store name only once in entity registry
|
||||
_create_entry(self.entity_registry, data[TAG_ID], data.get(CONF_NAME))
|
||||
_create_entry(self.entity_registry, data[CONF_ID], data.get(CONF_NAME))
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _get_suggested_id(self, info: dict[str, str]) -> str:
|
||||
"""Suggest an ID based on the config."""
|
||||
return info[TAG_ID]
|
||||
return info[CONF_ID]
|
||||
|
||||
async def _update_data(self, item: dict, update_data: dict) -> dict:
|
||||
"""Return a new updated data object."""
|
||||
data = {**item, **self.UPDATE_SCHEMA(update_data)}
|
||||
tag_id = data[TAG_ID]
|
||||
tag_id = item[CONF_ID]
|
||||
# make last_scanned JSON serializeable
|
||||
if LAST_SCANNED in update_data:
|
||||
data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
|
||||
@@ -211,7 +216,7 @@ class TagDictStorageCollectionWebsocket(
|
||||
item = {k: v for k, v in item.items() if k != "migrated"}
|
||||
if (
|
||||
entity_id := self.entity_registry.async_get_entity_id(
|
||||
DOMAIN, DOMAIN, item[TAG_ID]
|
||||
DOMAIN, DOMAIN, item[CONF_ID]
|
||||
)
|
||||
) and (entity := self.entity_registry.async_get(entity_id)):
|
||||
item[CONF_NAME] = entity.name or entity.original_name
|
||||
@@ -237,6 +242,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
).async_setup(hass)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_update_handlers: dict[str, Callable[[str | None, str | None], None]] = {}
|
||||
|
||||
async def tag_change_listener(
|
||||
change_type: str, item_id: str, updated_config: dict
|
||||
@@ -249,14 +255,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
if change_type == collection.CHANGE_ADDED:
|
||||
# When tags are added to storage
|
||||
entity = _create_entry(entity_registry, updated_config[TAG_ID], None)
|
||||
entity = _create_entry(entity_registry, updated_config[CONF_ID], None)
|
||||
if TYPE_CHECKING:
|
||||
assert entity.original_name
|
||||
await component.async_add_entities(
|
||||
[
|
||||
TagEntity(
|
||||
entity_update_handlers,
|
||||
entity.name or entity.original_name,
|
||||
updated_config[TAG_ID],
|
||||
updated_config[CONF_ID],
|
||||
updated_config.get(LAST_SCANNED),
|
||||
updated_config.get(DEVICE_ID),
|
||||
)
|
||||
@@ -265,18 +272,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
elif change_type == collection.CHANGE_UPDATED:
|
||||
# When tags are changed or updated in storage
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
SIGNAL_TAG_CHANGED,
|
||||
updated_config.get(DEVICE_ID),
|
||||
updated_config.get(LAST_SCANNED),
|
||||
)
|
||||
if handler := entity_update_handlers.get(updated_config[CONF_ID]):
|
||||
handler(
|
||||
updated_config.get(DEVICE_ID),
|
||||
updated_config.get(LAST_SCANNED),
|
||||
)
|
||||
|
||||
# Deleted tags
|
||||
elif change_type == collection.CHANGE_REMOVED:
|
||||
# When tags are removed from storage
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, DOMAIN, updated_config[TAG_ID]
|
||||
DOMAIN, DOMAIN, updated_config[CONF_ID]
|
||||
)
|
||||
if entity_id:
|
||||
entity_registry.async_remove(entity_id)
|
||||
@@ -287,21 +293,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
for tag in storage_collection.async_items():
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Adding tag: %s", tag)
|
||||
entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[TAG_ID])
|
||||
entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[CONF_ID])
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
DOMAIN, DOMAIN, tag[TAG_ID]
|
||||
DOMAIN, DOMAIN, tag[CONF_ID]
|
||||
):
|
||||
entity = entity_registry.async_get(entity_id)
|
||||
else:
|
||||
entity = _create_entry(entity_registry, tag[TAG_ID], None)
|
||||
entity = _create_entry(entity_registry, tag[CONF_ID], None)
|
||||
if TYPE_CHECKING:
|
||||
assert entity
|
||||
assert entity.original_name
|
||||
name = entity.name or entity.original_name
|
||||
entities.append(
|
||||
TagEntity(
|
||||
entity_update_handlers,
|
||||
name,
|
||||
tag[TAG_ID],
|
||||
tag[CONF_ID],
|
||||
tag.get(LAST_SCANNED),
|
||||
tag.get(DEVICE_ID),
|
||||
)
|
||||
@@ -363,12 +370,14 @@ class TagEntity(Entity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entity_update_handlers: dict[str, Callable[[str | None, str | None], None]],
|
||||
name: str,
|
||||
tag_id: str,
|
||||
last_scanned: str | None,
|
||||
device_id: str | None,
|
||||
) -> None:
|
||||
"""Initialize the Tag event."""
|
||||
self._entity_update_handlers = entity_update_handlers
|
||||
self._attr_name = name
|
||||
self._tag_id = tag_id
|
||||
self._attr_unique_id = tag_id
|
||||
@@ -411,10 +420,9 @@ class TagEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_TAG_CHANGED,
|
||||
self.async_handle_event,
|
||||
)
|
||||
)
|
||||
self._entity_update_handlers[self._tag_id] = self.async_handle_event
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle entity being removed."""
|
||||
await super().async_will_remove_from_hass()
|
||||
del self._entity_update_handlers[self._tag_id]
|
||||
|
||||
@@ -284,6 +284,14 @@ SERVICE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _read_file_as_bytesio(file_path: str) -> io.BytesIO:
|
||||
"""Read a file and return it as a BytesIO object."""
|
||||
with open(file_path, "rb") as file:
|
||||
data = io.BytesIO(file.read())
|
||||
data.name = file_path
|
||||
return data
|
||||
|
||||
|
||||
async def load_data(
|
||||
hass,
|
||||
url=None,
|
||||
@@ -342,7 +350,9 @@ async def load_data(
|
||||
)
|
||||
elif filepath is not None:
|
||||
if hass.config.is_allowed_path(filepath):
|
||||
return open(filepath, "rb")
|
||||
return await hass.async_add_executor_job(
|
||||
_read_file_as_bytesio, filepath
|
||||
)
|
||||
|
||||
_LOGGER.warning("'%s' are not secure to load data from!", filepath)
|
||||
else:
|
||||
|
||||
@@ -82,7 +82,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] =
|
||||
requires="components_battery",
|
||||
),
|
||||
TeslemetryNumberBatteryEntityDescription(
|
||||
key="off_grid_vehicle_charging_reserve",
|
||||
key="off_grid_vehicle_charging_reserve_percent",
|
||||
func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)),
|
||||
requires="components_off_grid_vehicle_charging_reserve_supported",
|
||||
),
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
"charge_state_charge_limit_soc": {
|
||||
"name": "Charge limit"
|
||||
},
|
||||
"off_grid_vehicle_charging_reserve": {
|
||||
"off_grid_vehicle_charging_reserve_percent": {
|
||||
"name": "Off grid reserve"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,7 +50,13 @@ class TibberNotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to Tibber devices."""
|
||||
migrate_notify_issue(self.hass, TIBBER_DOMAIN, "Tibber", "2024.12.0")
|
||||
migrate_notify_issue(
|
||||
self.hass,
|
||||
TIBBER_DOMAIN,
|
||||
"Tibber",
|
||||
"2024.12.0",
|
||||
service_name=self._service_name,
|
||||
)
|
||||
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
|
||||
try:
|
||||
await self._notify(title=title, message=message)
|
||||
|
||||
@@ -118,7 +118,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
translation_key="accumulated_consumption",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="accumulatedConsumptionLastHour",
|
||||
@@ -138,7 +138,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
translation_key="accumulated_production",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="accumulatedProductionLastHour",
|
||||
|
||||
@@ -35,7 +35,7 @@ class V2CSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[TrydanData], StateType]
|
||||
|
||||
|
||||
_SLAVE_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState]
|
||||
_METER_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState]
|
||||
|
||||
TRYDAN_SENSORS = (
|
||||
V2CSensorEntityDescription(
|
||||
@@ -80,12 +80,12 @@ TRYDAN_SENSORS = (
|
||||
value_fn=lambda evse_data: evse_data.fv_power,
|
||||
),
|
||||
V2CSensorEntityDescription(
|
||||
key="slave_error",
|
||||
translation_key="slave_error",
|
||||
key="meter_error",
|
||||
translation_key="meter_error",
|
||||
value_fn=lambda evse_data: evse_data.slave_error.name.lower(),
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=_SLAVE_ERROR_OPTIONS,
|
||||
options=_METER_ERROR_OPTIONS,
|
||||
),
|
||||
V2CSensorEntityDescription(
|
||||
key="battery_power",
|
||||
|
||||
@@ -54,18 +54,18 @@
|
||||
"battery_power": {
|
||||
"name": "Battery power"
|
||||
},
|
||||
"slave_error": {
|
||||
"name": "Slave error",
|
||||
"meter_error": {
|
||||
"name": "Meter error",
|
||||
"state": {
|
||||
"no_error": "No error",
|
||||
"communication": "Communication",
|
||||
"reading": "Reading",
|
||||
"slave": "Slave",
|
||||
"slave": "Meter",
|
||||
"waiting_wifi": "Waiting for Wi-Fi",
|
||||
"waiting_communication": "Waiting communication",
|
||||
"wrong_ip": "Wrong IP",
|
||||
"slave_not_found": "Slave not found",
|
||||
"wrong_slave": "Wrong slave",
|
||||
"slave_not_found": "Meter not found",
|
||||
"wrong_slave": "Wrong Meter",
|
||||
"no_response": "No response",
|
||||
"clamp_not_connected": "Clamp not connected",
|
||||
"illegal_function": "Illegal function",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user