forked from home-assistant/core
Compare commits
92 Commits
2024.6.0b5
...
2024.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b28cdcfc49 | |||
| 3f70e2b6f0 | |||
| ed22e98861 | |||
| 093f07c04e | |||
| b5693ca604 | |||
| 20b77aa15f | |||
| 1cbd3ab930 | |||
| 31b44b7846 | |||
| de3a0841d8 | |||
| 581fb2f9f4 | |||
| 5bb4e4f5d9 | |||
| cfa619b67e | |||
| 56db7fc7dc | |||
| 1f6be7b4d1 | |||
| 52d1432d81 | |||
| 14da1e9b23 | |||
| d6e1d05e87 | |||
| 62f73cfcca | |||
| 6e9a53d02e | |||
| 394c13af1d | |||
| 86b13e8ae3 | |||
| 5a7332a135 | |||
| 0f9a91d369 | |||
| 00dd86fb4b | |||
| 460909a7f6 | |||
| 21fd012447 | |||
| c27f0c560e | |||
| 0f4a1b421e | |||
| 5e35ce2996 | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -134,8 +134,15 @@ COOLDOWN_TIME = 60
|
||||
|
||||
|
||||
DEBUGGER_INTEGRATIONS = {"debugpy"}
|
||||
|
||||
# Core integrations are unconditionally loaded
|
||||
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
|
||||
LOGGING_INTEGRATIONS = {
|
||||
|
||||
# Integrations that are loaded right after the core is set up
|
||||
LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
|
||||
# isal is loaded right away before `http` to ensure if its
|
||||
# enabled, that `isal` is up to date.
|
||||
"isal",
|
||||
# Set log levels
|
||||
"logger",
|
||||
# Error logging
|
||||
@@ -214,8 +221,8 @@ CRITICAL_INTEGRATIONS = {
|
||||
}
|
||||
|
||||
SETUP_ORDER = (
|
||||
# Load logging as soon as possible
|
||||
("logging", LOGGING_INTEGRATIONS),
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
|
||||
# Setup frontend and recorder
|
||||
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}),
|
||||
# Start up debuggers. Start these first in case they want to wait.
|
||||
|
||||
@@ -43,6 +43,7 @@ class AgentBaseStation(AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
|
||||
@@ -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]},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"domain": "airgradient",
|
||||
"name": "Airgradient",
|
||||
"name": "AirGradient",
|
||||
"codeowners": ["@airgradienthq", "@joostlek"],
|
||||
"config_flow": true,
|
||||
"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)
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
|
||||
AirGradientSensorEntityDescription(
|
||||
key="pm003",
|
||||
translation_key="pm003_count",
|
||||
native_unit_of_measurement="particles/dL",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda status: status.pm003_count,
|
||||
),
|
||||
|
||||
@@ -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": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"name": "Nitrogen index"
|
||||
},
|
||||
"pm003_count": {
|
||||
"name": "PM0.3 count"
|
||||
"name": "PM0.3"
|
||||
},
|
||||
"raw_total_volatile_organic_component": {
|
||||
"name": "Raw total VOC"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
"title": "Setup your Azure Data Explorer integration",
|
||||
"description": "Enter connection details.",
|
||||
"data": {
|
||||
"clusteringesturi": "Cluster Ingest URI",
|
||||
"cluster_ingest_uri": "Cluster ingest URI",
|
||||
"database": "Database name",
|
||||
"table": "Table name",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"authority_id": "Authority ID"
|
||||
"authority_id": "Authority ID",
|
||||
"use_queued_ingestion": "Use queued ingestion"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ class BlinkSyncModuleHA(
|
||||
"""Representation of a Blink Alarm Control Panel."""
|
||||
|
||||
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
_attr_code_arm_required = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -4,11 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from . import DOMAIN, ClimateEntity
|
||||
from . import DOMAIN
|
||||
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
|
||||
@@ -23,7 +22,10 @@ class GetTemperatureIntent(intent.IntentHandler):
|
||||
|
||||
intent_type = INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str}
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
@@ -31,74 +33,24 @@ class GetTemperatureIntent(intent.IntentHandler):
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
component: EntityComponent[ClimateEntity] = hass.data[DOMAIN]
|
||||
entities: list[ClimateEntity] = list(component.entities)
|
||||
climate_entity: ClimateEntity | None = None
|
||||
climate_state: State | None = None
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
if not entities:
|
||||
raise intent.IntentHandleError("No climate entities")
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
name_slot = slots.get("name", {})
|
||||
entity_name: str | None = name_slot.get("value")
|
||||
entity_text: str | None = name_slot.get("text")
|
||||
|
||||
area_slot = slots.get("area", {})
|
||||
area_id = area_slot.get("value")
|
||||
|
||||
if area_id:
|
||||
# Filter by area and optionally name
|
||||
area_name = area_slot.get("text")
|
||||
|
||||
for maybe_climate in intent.async_match_states(
|
||||
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
|
||||
):
|
||||
climate_state = maybe_climate
|
||||
break
|
||||
|
||||
if climate_state is None:
|
||||
raise intent.NoStatesMatchedError(
|
||||
reason=intent.MatchFailedReason.AREA,
|
||||
name=entity_text or entity_name,
|
||||
area=area_name or area_id,
|
||||
floor=None,
|
||||
domains={DOMAIN},
|
||||
device_classes=None,
|
||||
)
|
||||
|
||||
climate_entity = component.get_entity(climate_state.entity_id)
|
||||
elif entity_name:
|
||||
# Filter by name
|
||||
for maybe_climate in intent.async_match_states(
|
||||
hass, name=entity_name, domains=[DOMAIN]
|
||||
):
|
||||
climate_state = maybe_climate
|
||||
break
|
||||
|
||||
if climate_state is None:
|
||||
raise intent.NoStatesMatchedError(
|
||||
reason=intent.MatchFailedReason.NAME,
|
||||
name=entity_name,
|
||||
area=None,
|
||||
floor=None,
|
||||
domains={DOMAIN},
|
||||
device_classes=None,
|
||||
)
|
||||
|
||||
climate_entity = component.get_entity(climate_state.entity_id)
|
||||
else:
|
||||
# First entity
|
||||
climate_entity = entities[0]
|
||||
climate_state = hass.states.get(climate_entity.entity_id)
|
||||
|
||||
assert climate_entity is not None
|
||||
|
||||
if climate_state is None:
|
||||
raise intent.IntentHandleError(f"No state for {climate_entity.name}")
|
||||
|
||||
assert climate_state is not None
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=[climate_state])
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -429,8 +429,15 @@ class DefaultAgent(ConversationEntity):
|
||||
intent_context=intent_context,
|
||||
language=language,
|
||||
):
|
||||
if ("name" in result.entities) and (
|
||||
not result.entities["name"].is_wildcard
|
||||
# Prioritize results with a "name" slot, but still prefer ones with
|
||||
# more literal text matched.
|
||||
if (
|
||||
("name" in result.entities)
|
||||
and (not result.entities["name"].is_wildcard)
|
||||
and (
|
||||
(name_result is None)
|
||||
or (result.text_chunks_matched > name_result.text_chunks_matched)
|
||||
)
|
||||
):
|
||||
name_result = result
|
||||
|
||||
@@ -871,7 +878,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.5"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ class EgardiaAlarm(AlarmControlPanelEntity):
|
||||
"""Representation of a Egardia alarm."""
|
||||
|
||||
_attr_state: str | None
|
||||
_attr_code_arm_required = False
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent):
|
||||
await session.async_ensure_token_valid()
|
||||
self.assistant = None
|
||||
if not self.assistant or user_input.language != self.language:
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN])
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
self.language = user_input.language
|
||||
self.assistant = TextAssistant(credentials, self.language)
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ async def async_send_text_commands(
|
||||
entry.async_start_reauth(hass)
|
||||
raise
|
||||
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN])
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass))
|
||||
with TextAssistant(
|
||||
credentials, language_code, audio_out=bool(media_players)
|
||||
|
||||
@@ -225,7 +225,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
messages = self.history[conversation_id]
|
||||
else:
|
||||
conversation_id = ulid.ulid_now()
|
||||
messages = [{}, {}]
|
||||
messages = [{}, {"role": "model", "parts": "Ok"}]
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
@@ -272,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(
|
||||
|
||||
@@ -93,7 +93,9 @@ async def async_setup_service(hass: HomeAssistant) -> None:
|
||||
|
||||
def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None:
|
||||
"""Run append in the executor."""
|
||||
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
|
||||
service = Client(
|
||||
Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
)
|
||||
try:
|
||||
sheet = service.open_by_key(entry.unique_id)
|
||||
except RefreshError:
|
||||
|
||||
@@ -61,7 +61,9 @@ class OAuth2FlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
|
||||
service = Client(
|
||||
Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
)
|
||||
|
||||
if self.reauth_entry:
|
||||
_LOGGER.debug("service.open_by_key")
|
||||
|
||||
@@ -267,15 +267,14 @@ class SupervisorIssues:
|
||||
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
|
||||
|
||||
if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
addons = get_addons_info(self._hass)
|
||||
if addons and issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
|
||||
"name"
|
||||
]
|
||||
if "url" in addons[issue.reference]:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = addons[
|
||||
issue.reference
|
||||
]["url"]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.TRIGGER
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.49", "babel==2.13.1"]
|
||||
"requirements": ["holidays==0.50", "babel==2.13.1"]
|
||||
}
|
||||
|
||||
@@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = {
|
||||
"fan",
|
||||
"humidifier",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"scene",
|
||||
"script",
|
||||
"switch",
|
||||
"todo",
|
||||
"vacuum",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"domain": "http",
|
||||
"name": "HTTP",
|
||||
"after_dependencies": ["isal"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/http",
|
||||
"integration_type": "system",
|
||||
|
||||
@@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler):
|
||||
intent_type = INTENT_HUMIDITY
|
||||
description = "Set desired humidity level"
|
||||
slot_schema = {
|
||||
vol.Required("name"): cv.string,
|
||||
vol.Required("name"): intent.non_empty_string,
|
||||
vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)),
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
@@ -44,18 +44,19 @@ class HumidityHandler(intent.IntentHandler):
|
||||
"""Handle the hass intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
states = list(
|
||||
intent.async_match_states(
|
||||
hass,
|
||||
name=slots["name"]["value"],
|
||||
states=hass.states.async_all(DOMAIN),
|
||||
)
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=slots["name"]["value"],
|
||||
domains=[DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
if not states:
|
||||
raise intent.IntentHandleError("No entities matched")
|
||||
|
||||
state = states[0]
|
||||
state = match_result.states[0]
|
||||
service_data = {ATTR_ENTITY_ID: state.entity_id}
|
||||
|
||||
humidity = slots["humidity"]["value"]
|
||||
@@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler):
|
||||
intent_type = INTENT_MODE
|
||||
description = "Set humidifier mode"
|
||||
slot_schema = {
|
||||
vol.Required("name"): cv.string,
|
||||
vol.Required("name"): intent.non_empty_string,
|
||||
vol.Required("mode"): cv.string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
@@ -98,18 +99,18 @@ class SetModeHandler(intent.IntentHandler):
|
||||
"""Handle the hass intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
states = list(
|
||||
intent.async_match_states(
|
||||
hass,
|
||||
name=slots["name"]["value"],
|
||||
states=hass.states.async_all(DOMAIN),
|
||||
)
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=slots["name"]["value"],
|
||||
domains=[DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
if not states:
|
||||
raise intent.IntentHandleError("No entities matched")
|
||||
|
||||
state = states[0]
|
||||
state = match_result.states[0]
|
||||
service_data = {ATTR_ENTITY_ID: state.entity_id}
|
||||
|
||||
intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes")
|
||||
|
||||
@@ -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.3"]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class IAlarmPanel(
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None:
|
||||
"""Create the entity with a DataUpdateCoordinator."""
|
||||
|
||||
@@ -195,13 +195,13 @@ class ImapMessage:
|
||||
):
|
||||
message_untyped_text = str(part.get_payload())
|
||||
|
||||
if message_text is not None:
|
||||
if message_text is not None and message_text.strip():
|
||||
return message_text
|
||||
|
||||
if message_html is not None:
|
||||
if message_html:
|
||||
return message_html
|
||||
|
||||
if message_untyped_text is not None:
|
||||
if message_untyped_text:
|
||||
return message_untyped_text
|
||||
|
||||
return str(self.email_message.get_payload())
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==1.0.1"]
|
||||
"requirements": ["imgw_pib==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -119,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
|
||||
)
|
||||
|
||||
@@ -154,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",
|
||||
|
||||
@@ -283,16 +283,13 @@ class KNXClimate(KnxEntity, ClimateEntity):
|
||||
)
|
||||
if knx_controller_mode in self._device.mode.controller_modes:
|
||||
await self._device.mode.set_controller_mode(knx_controller_mode)
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if self._device.supports_on_off:
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self._device.turn_off()
|
||||
elif not self._device.is_on:
|
||||
# for default hvac mode, otherwise above would have triggered
|
||||
await self._device.turn_on()
|
||||
self.async_write_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -48,6 +48,7 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity):
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(
|
||||
self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ class AsyncConfigEntryAuth(AbstractAuth):
|
||||
# even when it is expired to fully hand off this responsibility and
|
||||
# know it is working at startup (then if not, fail loudly).
|
||||
token = self._oauth_session.token
|
||||
creds = Credentials(
|
||||
creds = Credentials( # type: ignore[no-untyped-call]
|
||||
token=token["access_token"],
|
||||
refresh_token=token["refresh_token"],
|
||||
token_uri=OAUTH2_TOKEN,
|
||||
@@ -92,7 +92,7 @@ class AccessTokenAuthImpl(AbstractAuth):
|
||||
|
||||
async def async_get_creds(self) -> Credentials:
|
||||
"""Return an OAuth credential for Pub/Sub Subscriber."""
|
||||
return Credentials(
|
||||
return Credentials( # type: ignore[no-untyped-call]
|
||||
token=self._access_token,
|
||||
token_uri=OAUTH2_TOKEN,
|
||||
scopes=SDM_SCOPES,
|
||||
|
||||
@@ -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}`"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ class NX584Alarm(AlarmControlPanelEntity):
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(self, name: str, alarm_client: client.Client, url: str) -> None:
|
||||
"""Init the nx584 alarm panel."""
|
||||
|
||||
@@ -146,58 +146,58 @@ class OpenAIConversationEntity(
|
||||
messages = self.history[conversation_id]
|
||||
else:
|
||||
conversation_id = ulid.ulid_now()
|
||||
messages = []
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(
|
||||
user_input.context.user_id
|
||||
)
|
||||
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,
|
||||
)
|
||||
):
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
messages = [ChatCompletionSystemMessageParam(role="system", content=prompt)]
|
||||
|
||||
messages.append(
|
||||
ChatCompletionUserMessageParam(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(
|
||||
|
||||
@@ -240,6 +240,7 @@ class OverkizAlarmControlPanel(OverkizDescriptiveEntity, AlarmControlPanelEntity
|
||||
"""Representation of an Overkiz Alarm Control Panel."""
|
||||
|
||||
entity_description: OverkizAlarmDescription
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -55,6 +55,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
|
||||
"""The platform class required by Home Assistant."""
|
||||
|
||||
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(self, point_client: MinutPointClient, home_id: str) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -137,7 +137,7 @@ def _register_new_account(
|
||||
|
||||
configurator.request_done(hass, request_id)
|
||||
|
||||
request_id = configurator.async_request_config(
|
||||
request_id = configurator.request_config(
|
||||
hass,
|
||||
f"{DOMAIN} - {account_name}",
|
||||
callback=register_account_callback,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -584,11 +584,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
|
||||
raise UpdateFailed(
|
||||
f"Sleeping device did not update within {self.sleep_period} seconds interval"
|
||||
)
|
||||
if self.device.connected:
|
||||
return
|
||||
|
||||
if not await self._async_device_connect_task():
|
||||
raise UpdateFailed("Device reconnect error")
|
||||
async with self._connection_lock:
|
||||
if self.device.connected: # Already connected
|
||||
return
|
||||
|
||||
if not await self._async_device_connect_task():
|
||||
raise UpdateFailed("Device reconnect error")
|
||||
|
||||
async def _async_disconnected(self, reconnect: bool) -> None:
|
||||
"""Handle device disconnected."""
|
||||
@@ -737,7 +739,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 +759,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)
|
||||
):
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any
|
||||
import pysnmp.hlapi.asyncio as hlapi
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
CommunityData,
|
||||
ObjectIdentity,
|
||||
ObjectType,
|
||||
UdpTransportTarget,
|
||||
UsmUserData,
|
||||
getCmd,
|
||||
@@ -63,7 +65,12 @@ from .const import (
|
||||
MAP_PRIV_PROTOCOLS,
|
||||
SNMP_VERSIONS,
|
||||
)
|
||||
from .util import RequestArgsType, async_create_request_cmd_args
|
||||
from .util import (
|
||||
CommandArgsType,
|
||||
RequestArgsType,
|
||||
async_create_command_cmd_args,
|
||||
async_create_request_cmd_args,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,23 +132,23 @@ async def async_setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the SNMP switch."""
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
name: str = config[CONF_NAME]
|
||||
host: str = config[CONF_HOST]
|
||||
port: int = config[CONF_PORT]
|
||||
community = config.get(CONF_COMMUNITY)
|
||||
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)
|
||||
command_oid: str | None = config.get(CONF_COMMAND_OID)
|
||||
command_payload_on: str | None = config.get(CONF_COMMAND_PAYLOAD_ON)
|
||||
command_payload_off: str | None = config.get(CONF_COMMAND_PAYLOAD_OFF)
|
||||
version: str = config[CONF_VERSION]
|
||||
username = config.get(CONF_USERNAME)
|
||||
authkey = config.get(CONF_AUTH_KEY)
|
||||
authproto: str = config[CONF_AUTH_PROTOCOL]
|
||||
privkey = config.get(CONF_PRIV_KEY)
|
||||
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)
|
||||
payload_on: str = config[CONF_PAYLOAD_ON]
|
||||
payload_off: str = config[CONF_PAYLOAD_OFF]
|
||||
vartype: str = config[CONF_VARTYPE]
|
||||
|
||||
if version == "3":
|
||||
if not authkey:
|
||||
@@ -159,9 +166,11 @@ async def async_setup_platform(
|
||||
else:
|
||||
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
|
||||
|
||||
transport = UdpTransportTarget((host, port))
|
||||
request_args = await async_create_request_cmd_args(
|
||||
hass, auth_data, UdpTransportTarget((host, port)), baseoid
|
||||
hass, auth_data, transport, baseoid
|
||||
)
|
||||
command_args = await async_create_command_cmd_args(hass, auth_data, transport)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -177,6 +186,7 @@ async def async_setup_platform(
|
||||
command_payload_off,
|
||||
vartype,
|
||||
request_args,
|
||||
command_args,
|
||||
)
|
||||
],
|
||||
True,
|
||||
@@ -188,21 +198,22 @@ class SnmpSwitch(SwitchEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
baseoid,
|
||||
commandoid,
|
||||
payload_on,
|
||||
payload_off,
|
||||
command_payload_on,
|
||||
command_payload_off,
|
||||
vartype,
|
||||
request_args,
|
||||
name: str,
|
||||
host: str,
|
||||
port: int,
|
||||
baseoid: str,
|
||||
commandoid: str | None,
|
||||
payload_on: str,
|
||||
payload_off: str,
|
||||
command_payload_on: str | None,
|
||||
command_payload_off: str | None,
|
||||
vartype: str,
|
||||
request_args: RequestArgsType,
|
||||
command_args: CommandArgsType,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
self._baseoid = baseoid
|
||||
self._vartype = vartype
|
||||
|
||||
@@ -215,7 +226,8 @@ class SnmpSwitch(SwitchEntity):
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
self._target = UdpTransportTarget((host, port))
|
||||
self._request_args: RequestArgsType = request_args
|
||||
self._request_args = request_args
|
||||
self._command_args = command_args
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
@@ -226,7 +238,7 @@ class SnmpSwitch(SwitchEntity):
|
||||
"""Turn off the switch."""
|
||||
await self._execute_command(self._command_payload_off)
|
||||
|
||||
async def _execute_command(self, command):
|
||||
async def _execute_command(self, command: str) -> None:
|
||||
# User did not set vartype and command is not a digit
|
||||
if self._vartype == "none" and not self._command_payload_on.isdigit():
|
||||
await self._set(command)
|
||||
@@ -265,14 +277,12 @@ class SnmpSwitch(SwitchEntity):
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the switch's name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on; False if off. None if unknown."""
|
||||
return self._state
|
||||
|
||||
async def _set(self, value):
|
||||
await setCmd(*self._request_args, value)
|
||||
async def _set(self, value: Any) -> None:
|
||||
"""Set the state of the switch."""
|
||||
await setCmd(
|
||||
*self._command_args, ObjectType(ObjectIdentity(self._commandoid), value)
|
||||
)
|
||||
|
||||
@@ -25,6 +25,14 @@ DATA_SNMP_ENGINE = "snmp_engine"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CommandArgsType = tuple[
|
||||
SnmpEngine,
|
||||
UsmUserData | CommunityData,
|
||||
UdpTransportTarget | Udp6TransportTarget,
|
||||
ContextData,
|
||||
]
|
||||
|
||||
|
||||
type RequestArgsType = tuple[
|
||||
SnmpEngine,
|
||||
UsmUserData | CommunityData,
|
||||
@@ -34,20 +42,34 @@ type RequestArgsType = tuple[
|
||||
]
|
||||
|
||||
|
||||
async def async_create_command_cmd_args(
|
||||
hass: HomeAssistant,
|
||||
auth_data: UsmUserData | CommunityData,
|
||||
target: UdpTransportTarget | Udp6TransportTarget,
|
||||
) -> CommandArgsType:
|
||||
"""Create command arguments.
|
||||
|
||||
The ObjectType needs to be created dynamically by the caller.
|
||||
"""
|
||||
engine = await async_get_snmp_engine(hass)
|
||||
return (engine, auth_data, target, ContextData())
|
||||
|
||||
|
||||
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)),
|
||||
"""Create request arguments.
|
||||
|
||||
The same ObjectType is used for all requests.
|
||||
"""
|
||||
engine, auth_data, target, context_data = await async_create_command_cmd_args(
|
||||
hass, auth_data, target
|
||||
)
|
||||
object_type = ObjectType(ObjectIdentity(object_id))
|
||||
return (engine, auth_data, target, context_data, object_type)
|
||||
|
||||
|
||||
@singleton(DATA_SNMP_ENGINE)
|
||||
|
||||
@@ -62,6 +62,7 @@ class SpcAlarm(AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(self, area: Area, api: SpcWebGateway) -> None:
|
||||
"""Initialize the SPC alarm panel."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
f"{SIGNAL_TAG_CHANGED}-{updated_config[TAG_ID]}",
|
||||
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,
|
||||
f"{SIGNAL_TAG_CHANGED}-{self._tag_id}",
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity
|
||||
@@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler):
|
||||
|
||||
intent_type = INTENT_LIST_ADD_ITEM
|
||||
description = "Add item to a todo list"
|
||||
slot_schema = {"item": cv.string, "name": cv.string}
|
||||
slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
@@ -37,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler):
|
||||
target_list: TodoListEntity | None = None
|
||||
|
||||
# Find matching list
|
||||
for list_state in intent.async_match_states(
|
||||
hass, name=list_name, domains=[DOMAIN]
|
||||
):
|
||||
target_list = component.get_entity(list_state.entity_id)
|
||||
if target_list is not None:
|
||||
break
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
target_list = component.get_entity(match_result.states[0].entity_id)
|
||||
if target_list is None:
|
||||
raise intent.IntentHandleError(f"No to-do list: {list_name}")
|
||||
|
||||
assert target_list is not None
|
||||
|
||||
# Add to list
|
||||
await target_list.async_create_todo_item(
|
||||
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
|
||||
|
||||
@@ -88,6 +88,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
"""Tuya Alarm Entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_code_arm_required = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"fv_power": {
|
||||
"default": "mdi:solar-power-variant"
|
||||
},
|
||||
"slave_error": {
|
||||
"meter_error": {
|
||||
"default": "mdi:alert"
|
||||
},
|
||||
"battery_power": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user