forked from home-assistant/core
Compare commits
136 Commits
2024.8.0b1
...
2024.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94516de724 | ||
|
|
a2027fc78c | ||
|
|
be5577c2f9 | ||
|
|
93dc08a05f | ||
|
|
def2ace4ec | ||
|
|
4f0261d739 | ||
|
|
6103811de8 | ||
|
|
fd904c65a7 | ||
|
|
04bf8482b2 | ||
|
|
f5fd5e0457 | ||
|
|
0de89b42aa | ||
|
|
e8914552b1 | ||
|
|
bfd302109e | ||
|
|
796ad47dd0 | ||
|
|
e9915463a9 | ||
|
|
59aecda8cf | ||
|
|
7d00ccbbbc | ||
|
|
55a911120c | ||
|
|
80abf90c87 | ||
|
|
8539591307 | ||
|
|
6234deeee1 | ||
|
|
81fabb1bfa | ||
|
|
ff4e5859cf | ||
|
|
f2e42eafc7 | ||
|
|
63f28ae2fe | ||
|
|
5b6c6141c5 | ||
|
|
396ef7a642 | ||
|
|
17f59a5665 | ||
|
|
10846dc97b | ||
|
|
17bb00727d | ||
|
|
bc021dbbc6 | ||
|
|
e3cb9c0844 | ||
|
|
050e2c9404 | ||
|
|
5ea447ba48 | ||
|
|
a23b063922 | ||
|
|
c269d57259 | ||
|
|
d512f327c5 | ||
|
|
9bf8c5a54b | ||
|
|
725e2f16f5 | ||
|
|
d98d0cdad0 | ||
|
|
e2f4aa893f | ||
|
|
6b81fa89d3 | ||
|
|
c886587915 | ||
|
|
059d3eed98 | ||
|
|
f9ae2b4453 | ||
|
|
742c7ba23f | ||
|
|
e7ae5c5c24 | ||
|
|
ae4fc9504a | ||
|
|
2ef337ec2e | ||
|
|
723b7bd532 | ||
|
|
4fdb11b0d8 | ||
|
|
fe2e6c37f4 | ||
|
|
4a75c55a8f | ||
|
|
dfb59469cf | ||
|
|
bdb2e1e2e9 | ||
|
|
c4f6f1e3d8 | ||
|
|
fb3eae54ea | ||
|
|
d3f8fce788 | ||
|
|
44e58a8c87 | ||
|
|
3d3879b0db | ||
|
|
a8b1eb34f3 | ||
|
|
fd77058def | ||
|
|
b147ca6c5b | ||
|
|
670c4cacfa | ||
|
|
1ed0a89303 | ||
|
|
ab0597da7b | ||
|
|
a3db6bc8fa | ||
|
|
9bfc8f6e27 | ||
|
|
6fddef2dc5 | ||
|
|
ec08a85aa0 | ||
|
|
de7af575c5 | ||
|
|
d3831bae4e | ||
|
|
86722ba05e | ||
|
|
be4810731a | ||
|
|
ac6abb363c | ||
|
|
5367886732 | ||
|
|
7a51d4ff62 | ||
|
|
ef564c537d | ||
|
|
082290b092 | ||
|
|
4a212791a2 | ||
|
|
6bb55ce79e | ||
|
|
782ff12e6e | ||
|
|
af6f78a784 | ||
|
|
db32460f3b | ||
|
|
270990fe39 | ||
|
|
a10fed9d72 | ||
|
|
cc5699bf08 | ||
|
|
ad674a1c2b | ||
|
|
b0269faae4 | ||
|
|
1143efedc5 | ||
|
|
9e75b63925 | ||
|
|
940327dccf | ||
|
|
0270026f7c | ||
|
|
b636096ac3 | ||
|
|
a243ed5b23 | ||
|
|
3cf3780587 | ||
|
|
3d0a0cf376 | ||
|
|
7aae9d9ad3 | ||
|
|
870bb7efd4 | ||
|
|
35a6679ae9 | ||
|
|
a09d0117b1 | ||
|
|
e9fe98f7f9 | ||
|
|
5b2e188b52 | ||
|
|
c1953e938d | ||
|
|
77bcbbcf53 | ||
|
|
97587fae08 | ||
|
|
01b54fe1a9 | ||
|
|
f796950493 | ||
|
|
495fd946bc | ||
|
|
6af1e25d7e | ||
|
|
6d47a4d7e4 | ||
|
|
fd5533d719 | ||
|
|
d530137bec | ||
|
|
4f722e864c | ||
|
|
62d38e786d | ||
|
|
859874487e | ||
|
|
b16bf29819 | ||
|
|
6b10dbb38c | ||
|
|
ea20c4b375 | ||
|
|
0427aeccb0 | ||
|
|
4898ba932d | ||
|
|
35a3d2306c | ||
|
|
cdb378066c | ||
|
|
85700fd80f | ||
|
|
73a2ad7304 | ||
|
|
f6c4b6b045 | ||
|
|
0b4d921762 | ||
|
|
c8a0e5228d | ||
|
|
832bac8c63 | ||
|
|
eccce7017f | ||
|
|
fdb1baadbe | ||
|
|
7623ee49e4 | ||
|
|
fa241dcd04 | ||
|
|
bee77041e8 | ||
|
|
50b7eb44d1 | ||
|
|
7b1bf82e3c |
@@ -18,9 +18,12 @@ from homeassistant.const import (
|
||||
EVENT_THEMES_UPDATED,
|
||||
)
|
||||
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
|
||||
from homeassistant.util.event_type import EventType
|
||||
|
||||
# These are events that do not contain any sensitive data
|
||||
@@ -41,4 +44,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_SHOPPING_LIST_UPDATED,
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aemet",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aemet_opendata"],
|
||||
"requirements": ["AEMET-OpenData==0.5.3"]
|
||||
"requirements": ["AEMET-OpenData==0.5.4"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.7.1"],
|
||||
"requirements": ["airgradient==0.8.0"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
try:
|
||||
await airzone.get_version()
|
||||
except AirzoneError as err:
|
||||
except (AirzoneError, TimeoutError) as err:
|
||||
raise AbortFlow("cannot_connect") from err
|
||||
|
||||
return await self.async_step_discovered_connection()
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.1"]
|
||||
"requirements": ["aioairzone-cloud==0.6.2"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/alexa",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@balloob", "@synesthesiam"],
|
||||
"dependencies": ["conversation", "stt", "tts", "wake_word"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pymicro-vad==1.0.1"]
|
||||
|
||||
@@ -244,7 +244,6 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
self._status: Status | None = None
|
||||
self._inputs: list[Input] = []
|
||||
self._presets: list[Preset] = []
|
||||
self._is_online = False
|
||||
self._muted = False
|
||||
self._master: BluesoundPlayer | None = None
|
||||
self._is_master = False
|
||||
@@ -255,7 +254,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
self._attr_unique_id = format_unique_id(sync_status.mac, port)
|
||||
# there should always be one player with the default port per mac
|
||||
if port is DEFAULT_PORT:
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
@@ -312,26 +311,33 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
async def _start_poll_command(self):
|
||||
"""Loop which polls the status of the player."""
|
||||
try:
|
||||
while True:
|
||||
while True:
|
||||
try:
|
||||
await self.async_update_status()
|
||||
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.error("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
self.start_polling()
|
||||
|
||||
except CancelledError:
|
||||
_LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
|
||||
raise
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.error(
|
||||
"Node %s:%s is offline, retrying later", self.host, self.port
|
||||
)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
except CancelledError:
|
||||
_LOGGER.debug(
|
||||
"Stopping the polling of node %s:%s", self.host, self.port
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error in %s:%s, retrying later", self.host, self.port
|
||||
)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Start the polling task."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self._polling_task = self.hass.async_create_task(self._start_poll_command())
|
||||
self._polling_task = self.hass.async_create_background_task(
|
||||
self._start_poll_command(),
|
||||
name=f"bluesound.polling_{self.host}:{self.port}",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop the polling task."""
|
||||
@@ -345,7 +351,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update internal status of the entity."""
|
||||
if not self._is_online:
|
||||
if not self.available:
|
||||
return
|
||||
|
||||
with suppress(TimeoutError):
|
||||
@@ -362,7 +368,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
try:
|
||||
status = await self._player.status(etag=etag, poll_timeout=120, timeout=125)
|
||||
|
||||
self._is_online = True
|
||||
self._attr_available = True
|
||||
self._last_status_update = dt_util.utcnow()
|
||||
self._status = status
|
||||
|
||||
@@ -391,7 +397,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
self.async_write_ha_state()
|
||||
except (TimeoutError, ClientError):
|
||||
self._is_online = False
|
||||
self._attr_available = False
|
||||
self._last_status_update = None
|
||||
self._status = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"requirements": [
|
||||
"bleak==0.22.2",
|
||||
"bleak-retry-connector==3.5.0",
|
||||
"bluetooth-adapters==0.19.3",
|
||||
"bluetooth-adapters==0.19.4",
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.19.4",
|
||||
"dbus-fast==2.22.1",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["dio_chacon_api"],
|
||||
"requirements": ["dio-chacon-wifi-api==1.1.0"]
|
||||
"requirements": ["dio-chacon-wifi-api==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from coinbase.wallet.client import Client
|
||||
from coinbase.rest import RESTClient
|
||||
from coinbase.rest.rest_base import HTTPError
|
||||
from coinbase.wallet.client import Client as LegacyClient
|
||||
from coinbase.wallet.error import AuthenticationError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -15,8 +17,23 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_AVALIABLE,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_HOLD,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNTS_DATA,
|
||||
API_ACCOUNT_NAME,
|
||||
API_ACCOUNT_VALUE,
|
||||
API_ACCOUNTS,
|
||||
API_DATA,
|
||||
API_RATES_CURRENCY,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
API_V3_ACCOUNT_ID,
|
||||
API_V3_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_BASE,
|
||||
CONF_EXCHANGE_RATES,
|
||||
@@ -59,9 +76,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
|
||||
"""Create and update a Coinbase Data instance."""
|
||||
client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
|
||||
if "organizations" not in entry.data[CONF_API_KEY]:
|
||||
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
|
||||
version = "v2"
|
||||
else:
|
||||
client = RESTClient(
|
||||
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
|
||||
)
|
||||
version = "v3"
|
||||
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
|
||||
instance = CoinbaseData(client, base_rate)
|
||||
instance = CoinbaseData(client, base_rate, version)
|
||||
instance.update()
|
||||
return instance
|
||||
|
||||
@@ -86,42 +110,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
|
||||
registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
def get_accounts(client):
|
||||
def get_accounts(client, version):
|
||||
"""Handle paginated accounts."""
|
||||
response = client.get_accounts()
|
||||
accounts = response[API_ACCOUNTS_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
while next_starting_after:
|
||||
response = client.get_accounts(starting_after=next_starting_after)
|
||||
accounts += response[API_ACCOUNTS_DATA]
|
||||
if version == "v2":
|
||||
accounts = response[API_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
return accounts
|
||||
while next_starting_after:
|
||||
response = client.get_accounts(starting_after=next_starting_after)
|
||||
accounts += response[API_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
return [
|
||||
{
|
||||
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
|
||||
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
|
||||
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
],
|
||||
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
|
||||
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
|
||||
}
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
accounts = response[API_ACCOUNTS]
|
||||
while response["has_next"]:
|
||||
response = client.get_accounts(cursor=response["cursor"])
|
||||
accounts += response["accounts"]
|
||||
|
||||
return [
|
||||
{
|
||||
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
|
||||
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
|
||||
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
|
||||
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
|
||||
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
|
||||
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
|
||||
}
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
class CoinbaseData:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, client, exchange_base):
|
||||
def __init__(self, client, exchange_base, version):
|
||||
"""Init the coinbase data object."""
|
||||
|
||||
self.client = client
|
||||
self.accounts = None
|
||||
self.exchange_base = exchange_base
|
||||
self.exchange_rates = None
|
||||
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
|
||||
if version == "v2":
|
||||
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
|
||||
else:
|
||||
self.user_id = (
|
||||
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
|
||||
)
|
||||
self.api_version = version
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
"""Get the latest data from coinbase."""
|
||||
|
||||
try:
|
||||
self.accounts = get_accounts(self.client)
|
||||
self.exchange_rates = self.client.get_exchange_rates(
|
||||
currency=self.exchange_base
|
||||
)
|
||||
except AuthenticationError as coinbase_error:
|
||||
self.accounts = get_accounts(self.client, self.api_version)
|
||||
if self.api_version == "v2":
|
||||
self.exchange_rates = self.client.get_exchange_rates(
|
||||
currency=self.exchange_base
|
||||
)
|
||||
else:
|
||||
self.exchange_rates = self.client.get(
|
||||
"/v2/exchange-rates",
|
||||
params={API_RATES_CURRENCY: self.exchange_base},
|
||||
)[API_DATA]
|
||||
except (AuthenticationError, HTTPError) as coinbase_error:
|
||||
_LOGGER.error(
|
||||
"Authentication error connecting to coinbase: %s", coinbase_error
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from coinbase.wallet.client import Client
|
||||
from coinbase.rest import RESTClient
|
||||
from coinbase.rest.rest_base import HTTPError
|
||||
from coinbase.wallet.client import Client as LegacyClient
|
||||
from coinbase.wallet.error import AuthenticationError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -15,18 +17,17 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import get_accounts
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_DATA,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_BASE,
|
||||
CONF_EXCHANGE_PRECISION,
|
||||
@@ -49,8 +50,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
|
||||
def get_user_from_client(api_key, api_token):
|
||||
"""Get the user name from Coinbase API credentials."""
|
||||
client = Client(api_key, api_token)
|
||||
return client.get_current_user()
|
||||
if "organizations" not in api_key:
|
||||
client = LegacyClient(api_key, api_token)
|
||||
return client.get_current_user()["name"]
|
||||
client = RESTClient(api_key=api_key, api_secret=api_token)
|
||||
return client.get_portfolios()["portfolios"][0]["name"]
|
||||
|
||||
|
||||
async def validate_api(hass: HomeAssistant, data):
|
||||
@@ -60,11 +64,13 @@ async def validate_api(hass: HomeAssistant, data):
|
||||
user = await hass.async_add_executor_job(
|
||||
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
|
||||
)
|
||||
except AuthenticationError as error:
|
||||
if "api key" in str(error):
|
||||
except (AuthenticationError, HTTPError) as error:
|
||||
if "api key" in str(error) or " 401 Client Error" in str(error):
|
||||
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
|
||||
raise InvalidKey from error
|
||||
if "invalid signature" in str(error):
|
||||
if "invalid signature" in str(
|
||||
error
|
||||
) or "'Could not deserialize key data" in str(error):
|
||||
_LOGGER.debug(
|
||||
"Coinbase rejected API credentials due to an invalid API secret"
|
||||
)
|
||||
@@ -73,8 +79,8 @@ async def validate_api(hass: HomeAssistant, data):
|
||||
raise InvalidAuth from error
|
||||
except ConnectionError as error:
|
||||
raise CannotConnect from error
|
||||
|
||||
return {"title": user["name"]}
|
||||
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
|
||||
return {"title": user, "api_version": api_version}
|
||||
|
||||
|
||||
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
|
||||
@@ -82,14 +88,20 @@ async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, optio
|
||||
|
||||
client = hass.data[DOMAIN][config_entry.entry_id].client
|
||||
|
||||
accounts = await hass.async_add_executor_job(get_accounts, client)
|
||||
accounts = await hass.async_add_executor_job(
|
||||
get_accounts, client, config_entry.data.get("api_version", "v2")
|
||||
)
|
||||
|
||||
accounts_currencies = [
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
for account in accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
if not account[ACCOUNT_IS_VAULT]
|
||||
]
|
||||
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
|
||||
if config_entry.data.get("api_version", "v2") == "v2":
|
||||
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
|
||||
else:
|
||||
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
|
||||
available_rates = resp[API_DATA]
|
||||
if CONF_CURRENCIES in options:
|
||||
for currency in options[CONF_CURRENCIES]:
|
||||
if currency not in accounts_currencies:
|
||||
@@ -134,6 +146,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
user_input[CONF_API_VERSION] = info["api_version"]
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants used for Coinbase."""
|
||||
|
||||
ACCOUNT_IS_VAULT = "is_vault"
|
||||
|
||||
CONF_CURRENCIES = "account_balance_currencies"
|
||||
CONF_EXCHANGE_BASE = "exchange_base"
|
||||
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
|
||||
@@ -10,18 +12,25 @@ DOMAIN = "coinbase"
|
||||
|
||||
# Constants for data returned by Coinbase API
|
||||
API_ACCOUNT_AMOUNT = "amount"
|
||||
API_ACCOUNT_AVALIABLE = "available_balance"
|
||||
API_ACCOUNT_BALANCE = "balance"
|
||||
API_ACCOUNT_CURRENCY = "currency"
|
||||
API_ACCOUNT_CURRENCY_CODE = "code"
|
||||
API_ACCOUNT_HOLD = "hold"
|
||||
API_ACCOUNT_ID = "id"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
||||
API_ACCOUNT_NAME = "name"
|
||||
API_ACCOUNTS_DATA = "data"
|
||||
API_ACCOUNT_VALUE = "value"
|
||||
API_ACCOUNTS = "accounts"
|
||||
API_DATA = "data"
|
||||
API_RATES = "rates"
|
||||
API_RATES_CURRENCY = "currency"
|
||||
API_RESOURCE_PATH = "resource_path"
|
||||
API_RESOURCE_TYPE = "type"
|
||||
API_TYPE_VAULT = "vault"
|
||||
API_USD = "USD"
|
||||
API_V3_ACCOUNT_ID = "uuid"
|
||||
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
|
||||
|
||||
WALLETS = {
|
||||
"1INCH": "1INCH",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/coinbase",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["coinbase"],
|
||||
"requirements": ["coinbase==2.1.0"]
|
||||
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
|
||||
}
|
||||
|
||||
@@ -12,15 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import CoinbaseData
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNT_NAME,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_PRECISION,
|
||||
CONF_EXCHANGE_PRECISION_DEFAULT,
|
||||
@@ -31,6 +28,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_NATIVE_BALANCE = "Balance in native currency"
|
||||
ATTR_API_VERSION = "API Version"
|
||||
|
||||
CURRENCY_ICONS = {
|
||||
"BTC": "mdi:currency-btc",
|
||||
@@ -56,9 +54,9 @@ async def async_setup_entry(
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
provided_currencies: list[str] = [
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
for account in instance.accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
if not account[ACCOUNT_IS_VAULT]
|
||||
]
|
||||
|
||||
desired_currencies: list[str] = []
|
||||
@@ -73,6 +71,11 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for currency in desired_currencies:
|
||||
_LOGGER.debug(
|
||||
"Attempting to set up %s account sensor with %s API",
|
||||
currency,
|
||||
instance.api_version,
|
||||
)
|
||||
if currency not in provided_currencies:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
@@ -85,12 +88,17 @@ async def async_setup_entry(
|
||||
entities.append(AccountSensor(instance, currency))
|
||||
|
||||
if CONF_EXCHANGE_RATES in config_entry.options:
|
||||
entities.extend(
|
||||
ExchangeRateSensor(
|
||||
instance, rate, exchange_base_currency, exchange_precision
|
||||
for rate in config_entry.options[CONF_EXCHANGE_RATES]:
|
||||
_LOGGER.debug(
|
||||
"Attempting to set up %s account sensor with %s API",
|
||||
rate,
|
||||
instance.api_version,
|
||||
)
|
||||
entities.append(
|
||||
ExchangeRateSensor(
|
||||
instance, rate, exchange_base_currency, exchange_precision
|
||||
)
|
||||
)
|
||||
for rate in config_entry.options[CONF_EXCHANGE_RATES]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -105,26 +113,21 @@ class AccountSensor(SensorEntity):
|
||||
self._coinbase_data = coinbase_data
|
||||
self._currency = currency
|
||||
for account in coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
|
||||
continue
|
||||
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
||||
self._attr_unique_id = (
|
||||
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
|
||||
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
|
||||
f"{account[API_ACCOUNT_CURRENCY]}"
|
||||
)
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
]
|
||||
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
|
||||
self._attr_icon = CURRENCY_ICONS.get(
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
|
||||
account[API_ACCOUNT_CURRENCY],
|
||||
DEFAULT_COIN_ICON,
|
||||
)
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
float(account[API_ACCOUNT_AMOUNT])
|
||||
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
|
||||
2,
|
||||
)
|
||||
@@ -144,21 +147,26 @@ class AccountSensor(SensorEntity):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
||||
ATTR_API_VERSION: self._coinbase_data.api_version,
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest state of the sensor."""
|
||||
_LOGGER.debug(
|
||||
"Updating %s account sensor with %s API",
|
||||
self._currency,
|
||||
self._coinbase_data.api_version,
|
||||
)
|
||||
self._coinbase_data.update()
|
||||
for account in self._coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
!= self._currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
account[API_ACCOUNT_CURRENCY] != self._currency
|
||||
or account[ACCOUNT_IS_VAULT]
|
||||
):
|
||||
continue
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
float(account[API_ACCOUNT_AMOUNT])
|
||||
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
2,
|
||||
)
|
||||
@@ -202,8 +210,13 @@ class ExchangeRateSensor(SensorEntity):
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest state of the sensor."""
|
||||
_LOGGER.debug(
|
||||
"Updating %s rate sensor with %s API",
|
||||
self._currency,
|
||||
self._coinbase_data.api_version,
|
||||
)
|
||||
self._coinbase_data.update()
|
||||
self._attr_native_value = round(
|
||||
1 / float(self._coinbase_data.exchange_rates.rates[self._currency]),
|
||||
1 / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
self._precision,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Support for Concord232 alarm control panels."""
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
# from concord232 import client as concord232_client
|
||||
from concord232 import client as concord232_client
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Support for exposing Concord232 elements as sensors."""
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
# from concord232 import client as concord232_client
|
||||
from concord232 import client as concord232_client
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"domain": "concord232",
|
||||
"name": "Concord232",
|
||||
"codeowners": [],
|
||||
"disabled": "This integration is disabled because it uses non-open source code to operate.",
|
||||
"documentation": "https://www.home-assistant.io/integrations/concord232",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["concord232", "stevedore"],
|
||||
"requirements": ["concord232==0.15"]
|
||||
"requirements": ["concord232==0.15.1"]
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
extend = "../../../pyproject.toml"
|
||||
|
||||
lint.extend-ignore = [
|
||||
"F821"
|
||||
]
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.29"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.7"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycoolmasternet_async"],
|
||||
"requirements": ["pycoolmasternet-async==0.2.0"]
|
||||
"requirements": ["pycoolmasternet-async==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.13.1"],
|
||||
"requirements": ["pydaikin==2.13.4"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ class ConfiguredDoorBird:
|
||||
title: str | None = data.get("title")
|
||||
if not title or not title.startswith("Home Assistant"):
|
||||
continue
|
||||
event = title.split("(")[1].strip(")")
|
||||
event = title.partition("(")[2].strip(")")
|
||||
if input_type := favorite_input_type.get(identifier):
|
||||
events.append(DoorbirdEvent(event, input_type))
|
||||
elif input_type := default_event_types.get(event):
|
||||
|
||||
@@ -431,39 +431,42 @@ def rename_old_gas_to_mbus(
|
||||
) -> None:
|
||||
"""Rename old gas sensor to mbus variant."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
|
||||
if device_entry_v1 is not None:
|
||||
device_id = device_entry_v1.id
|
||||
for dev_id in (mbus_device_id, entry.entry_id):
|
||||
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, dev_id)})
|
||||
if device_entry_v1 is not None:
|
||||
device_id = device_entry_v1.id
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
ent_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
|
||||
for entity in entries:
|
||||
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
new_unique_id=mbus_device_id,
|
||||
device_id=mbus_device_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
"Skip migration of %s because it already exists",
|
||||
entity.entity_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Migrated entity %s from unique id %s to %s",
|
||||
entity.entity_id,
|
||||
entity.unique_id,
|
||||
mbus_device_id,
|
||||
)
|
||||
# Cleanup old device
|
||||
dev_entities = er.async_entries_for_device(
|
||||
ent_reg, device_id, include_disabled_entities=True
|
||||
)
|
||||
if not dev_entities:
|
||||
dev_reg.async_remove_device(device_id)
|
||||
for entity in entries:
|
||||
if entity.unique_id.endswith(
|
||||
"belgium_5min_gas_meter_reading"
|
||||
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
new_unique_id=mbus_device_id,
|
||||
device_id=mbus_device_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
"Skip migration of %s because it already exists",
|
||||
entity.entity_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Migrated entity %s from unique id %s to %s",
|
||||
entity.entity_id,
|
||||
entity.unique_id,
|
||||
mbus_device_id,
|
||||
)
|
||||
# Cleanup old device
|
||||
dev_entities = er.async_entries_for_device(
|
||||
ent_reg, device_id, include_disabled_entities=True
|
||||
)
|
||||
if not dev_entities:
|
||||
dev_reg.async_remove_device(device_id)
|
||||
|
||||
|
||||
def is_supported_description(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any, cast
|
||||
@@ -105,11 +106,14 @@ async def _validate_input(
|
||||
if not user_input.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url:
|
||||
ssl_context = get_default_no_verify_context()
|
||||
|
||||
mqtt_config = create_mqtt_config(
|
||||
device_id=device_id,
|
||||
country=country,
|
||||
override_mqtt_url=mqtt_url,
|
||||
ssl_context=ssl_context,
|
||||
mqtt_config = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_mqtt_config,
|
||||
device_id=device_id,
|
||||
country=country,
|
||||
override_mqtt_url=mqtt_url,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
)
|
||||
|
||||
client = MqttClient(mqtt_config, authenticator)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
@@ -64,32 +65,28 @@ class EcovacsController:
|
||||
if not config.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url:
|
||||
ssl_context = get_default_no_verify_context()
|
||||
|
||||
self._mqtt = MqttClient(
|
||||
create_mqtt_config(
|
||||
device_id=self._device_id,
|
||||
country=country,
|
||||
override_mqtt_url=mqtt_url,
|
||||
ssl_context=ssl_context,
|
||||
),
|
||||
self._authenticator,
|
||||
self._mqtt_config_fn = partial(
|
||||
create_mqtt_config,
|
||||
device_id=self._device_id,
|
||||
country=country,
|
||||
override_mqtt_url=mqtt_url,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
self._mqtt_client: MqttClient | None = None
|
||||
|
||||
self._added_legacy_entities: set[str] = set()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Init controller."""
|
||||
mqtt_config_verfied = False
|
||||
try:
|
||||
devices = await self._api_client.get_devices()
|
||||
credentials = await self._authenticator.authenticate()
|
||||
for device_config in devices:
|
||||
if isinstance(device_config, DeviceInfo):
|
||||
# MQTT device
|
||||
if not mqtt_config_verfied:
|
||||
await self._mqtt.verify_config()
|
||||
mqtt_config_verfied = True
|
||||
device = Device(device_config, self._authenticator)
|
||||
await device.initialize(self._mqtt)
|
||||
mqtt = await self._get_mqtt_client()
|
||||
await device.initialize(mqtt)
|
||||
self._devices.append(device)
|
||||
else:
|
||||
# Legacy device
|
||||
@@ -116,7 +113,8 @@ class EcovacsController:
|
||||
await device.teardown()
|
||||
for legacy_device in self._legacy_devices:
|
||||
await self._hass.async_add_executor_job(legacy_device.disconnect)
|
||||
await self._mqtt.disconnect()
|
||||
if self._mqtt_client is not None:
|
||||
await self._mqtt_client.disconnect()
|
||||
await self._authenticator.teardown()
|
||||
|
||||
def add_legacy_entity(self, device: VacBot, component: str) -> None:
|
||||
@@ -127,6 +125,16 @@ class EcovacsController:
|
||||
"""Check if legacy entity is added."""
|
||||
return f"{device.vacuum['did']}_{component}" in self._added_legacy_entities
|
||||
|
||||
async def _get_mqtt_client(self) -> MqttClient:
|
||||
"""Return validated MQTT client."""
|
||||
if self._mqtt_client is None:
|
||||
config = await self._hass.async_add_executor_job(self._mqtt_config_fn)
|
||||
mqtt = MqttClient(config, self._authenticator)
|
||||
await mqtt.verify_config()
|
||||
self._mqtt_client = mqtt
|
||||
|
||||
return self._mqtt_client
|
||||
|
||||
@property
|
||||
def devices(self) -> list[Device]:
|
||||
"""Return devices."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==8.2.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==8.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openwebif"],
|
||||
"requirements": ["openwebifpy==4.2.5"]
|
||||
"requirements": ["openwebifpy==4.2.7"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.20.6"],
|
||||
"requirements": ["pyenphase==1.22.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
@@ -190,10 +192,12 @@ def get_forecast(ec_data, hourly) -> list[Forecast] | None:
|
||||
if not (half_days := ec_data.daily_forecasts):
|
||||
return None
|
||||
|
||||
def get_day_forecast(fcst: list[dict[str, str]]) -> Forecast:
|
||||
def get_day_forecast(
|
||||
fcst: list[dict[str, Any]],
|
||||
) -> Forecast:
|
||||
high_temp = int(fcst[0]["temperature"]) if len(fcst) == 2 else None
|
||||
return {
|
||||
ATTR_FORECAST_TIME: fcst[0]["timestamp"],
|
||||
ATTR_FORECAST_TIME: fcst[0]["timestamp"].isoformat(),
|
||||
ATTR_FORECAST_NATIVE_TEMP: high_temp,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW: int(fcst[-1]["temperature"]),
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: int(
|
||||
|
||||
@@ -346,7 +346,7 @@ class ESPHomeManager:
|
||||
) -> int | None:
|
||||
"""Start a voice assistant pipeline."""
|
||||
if self.voice_assistant_pipeline is not None:
|
||||
_LOGGER.warning("Voice assistant UDP server was not stopped")
|
||||
_LOGGER.warning("Previous Voice assistant pipeline was not stopped")
|
||||
self.voice_assistant_pipeline.stop()
|
||||
self.voice_assistant_pipeline = None
|
||||
|
||||
@@ -654,12 +654,13 @@ def _async_setup_device_registry(
|
||||
if device_info.manufacturer:
|
||||
manufacturer = device_info.manufacturer
|
||||
model = device_info.model
|
||||
hw_version = None
|
||||
if device_info.project_name:
|
||||
project_name = device_info.project_name.split(".")
|
||||
manufacturer = project_name[0]
|
||||
model = project_name[1]
|
||||
hw_version = device_info.project_version
|
||||
sw_version = (
|
||||
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
|
||||
)
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
@@ -674,7 +675,6 @@ def _async_setup_device_registry(
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
hw_version=hw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
return device_entry.id
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==24.6.2",
|
||||
"aioesphomeapi==25.0.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==1.0.0"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from aioesphomeapi import (
|
||||
DeviceInfo as ESPHomeDeviceInfo,
|
||||
EntityInfo,
|
||||
UpdateCommand,
|
||||
UpdateInfo,
|
||||
UpdateState,
|
||||
)
|
||||
@@ -259,9 +260,15 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
"""Return the title of the update."""
|
||||
return self._state.title
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_update(self) -> None:
|
||||
"""Command device to check for update."""
|
||||
if self.available:
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.CHECK)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Update the current value."""
|
||||
self._client.update_command(key=self._key, install=True)
|
||||
"""Command device to install update."""
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.INSTALL)
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"name": "FFmpeg",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ffmpeg",
|
||||
"integration_type": "system",
|
||||
"requirements": ["ha-ffmpeg==3.2.0"]
|
||||
}
|
||||
|
||||
@@ -653,8 +653,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
|
||||
entity_reg, config_entry.entry_id
|
||||
)
|
||||
|
||||
orphan_macs: set[str] = set()
|
||||
for entity in entities:
|
||||
entry_mac = entity.unique_id.split("_")[0]
|
||||
if (
|
||||
@@ -662,17 +660,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
or "_internet_access" in entity.unique_id
|
||||
) and entry_mac not in device_hosts:
|
||||
_LOGGER.info("Removing orphan entity entry %s", entity.entity_id)
|
||||
orphan_macs.add(entry_mac)
|
||||
entity_reg.async_remove(entity.entity_id)
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
orphan_connections = {
|
||||
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in orphan_macs
|
||||
valid_connections = {
|
||||
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in device_hosts
|
||||
}
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_reg, config_entry.entry_id
|
||||
):
|
||||
if any(con in device.connections for con in orphan_connections):
|
||||
if not any(con in device.connections for con in valid_connections):
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
|
||||
@@ -398,6 +398,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
static_paths_configs: list[StaticPathConfig] = []
|
||||
|
||||
for path, should_cache in (
|
||||
("service_worker.js", False),
|
||||
("sw-modern.js", False),
|
||||
("sw-modern.js.map", False),
|
||||
("sw-legacy.js", False),
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240802.0"]
|
||||
"requirements": ["home-assistant-frontend==20240809.0"]
|
||||
}
|
||||
|
||||
@@ -45,15 +45,13 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except exceptions.GlancesApiError as err:
|
||||
raise UpdateFailed from err
|
||||
# Update computed values
|
||||
uptime: datetime | None = self.data["computed"]["uptime"] if self.data else None
|
||||
uptime: datetime | None = None
|
||||
up_duration: timedelta | None = None
|
||||
if up_duration := parse_duration(data.get("uptime")):
|
||||
if "uptime" in data and (up_duration := parse_duration(data["uptime"])):
|
||||
uptime = self.data["computed"]["uptime"] if self.data else None
|
||||
# Update uptime if previous value is None or previous uptime is bigger than
|
||||
# new uptime (i.e. server restarted)
|
||||
if (
|
||||
self.data is None
|
||||
or self.data["computed"]["uptime_duration"] > up_duration
|
||||
):
|
||||
if uptime is None or self.data["computed"]["uptime_duration"] > up_duration:
|
||||
uptime = utcnow() - up_duration
|
||||
data["computed"] = {"uptime_duration": up_duration, "uptime": uptime}
|
||||
return data or {}
|
||||
|
||||
@@ -325,6 +325,7 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
|
||||
|
||||
entity_description: GlancesSensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
_data_valid: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -351,14 +352,7 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Set sensor unavailable when native value is invalid."""
|
||||
if super().available:
|
||||
return (
|
||||
not self._numeric_state_expected
|
||||
or isinstance(value := self.native_value, (int, float))
|
||||
or isinstance(value, str)
|
||||
and value.isnumeric()
|
||||
)
|
||||
return False
|
||||
return super().available and self._data_valid
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
@@ -368,10 +362,19 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
|
||||
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update sensor native value from coordinator data."""
|
||||
data = self.coordinator.data[self.entity_description.type]
|
||||
if dict_val := data.get(self._sensor_label):
|
||||
data = self.coordinator.data.get(self.entity_description.type)
|
||||
if data and (dict_val := data.get(self._sensor_label)):
|
||||
self._attr_native_value = dict_val.get(self.entity_description.key)
|
||||
elif self.entity_description.key in data:
|
||||
elif data and (self.entity_description.key in data):
|
||||
self._attr_native_value = data.get(self.entity_description.key)
|
||||
else:
|
||||
self._attr_native_value = None
|
||||
self._update_data_valid()
|
||||
|
||||
def _update_data_valid(self) -> None:
|
||||
self._data_valid = self._attr_native_value is not None and (
|
||||
not self._numeric_state_expected
|
||||
or isinstance(self._attr_native_value, (int, float))
|
||||
or isinstance(self._attr_native_value, str)
|
||||
and self._attr_native_value.isnumeric()
|
||||
)
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_GENDER,
|
||||
description={"suggested_value": config_options.get(CONF_GENDER)},
|
||||
default=texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
|
||||
default=config_options.get(
|
||||
CONF_GENDER,
|
||||
texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
|
||||
),
|
||||
): vol.All(
|
||||
vol.Upper,
|
||||
SelectSelector(
|
||||
@@ -72,7 +75,7 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_VOICE,
|
||||
description={"suggested_value": config_options.get(CONF_VOICE)},
|
||||
default=DEFAULT_VOICE,
|
||||
default=config_options.get(CONF_VOICE, DEFAULT_VOICE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
@@ -82,7 +85,10 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_ENCODING,
|
||||
description={"suggested_value": config_options.get(CONF_ENCODING)},
|
||||
default=texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
|
||||
default=config_options.get(
|
||||
CONF_ENCODING,
|
||||
texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
|
||||
),
|
||||
): vol.All(
|
||||
vol.Upper,
|
||||
SelectSelector(
|
||||
@@ -95,22 +101,22 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_SPEED,
|
||||
description={"suggested_value": config_options.get(CONF_SPEED)},
|
||||
default=1.0,
|
||||
default=config_options.get(CONF_SPEED, 1.0),
|
||||
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
|
||||
vol.Optional(
|
||||
CONF_PITCH,
|
||||
description={"suggested_value": config_options.get(CONF_PITCH)},
|
||||
default=0,
|
||||
default=config_options.get(CONF_PITCH, 0),
|
||||
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
|
||||
vol.Optional(
|
||||
CONF_GAIN,
|
||||
description={"suggested_value": config_options.get(CONF_GAIN)},
|
||||
default=0,
|
||||
default=config_options.get(CONF_GAIN, 0),
|
||||
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
|
||||
vol.Optional(
|
||||
CONF_PROFILES,
|
||||
description={"suggested_value": config_options.get(CONF_PROFILES)},
|
||||
default=[],
|
||||
default=config_options.get(CONF_PROFILES, []),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
@@ -132,7 +138,7 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_TEXT_TYPE,
|
||||
description={"suggested_value": config_options.get(CONF_TEXT_TYPE)},
|
||||
default="text",
|
||||
default=config_options.get(CONF_TEXT_TYPE, "text"),
|
||||
): vol.All(
|
||||
vol.Lower,
|
||||
SelectSelector(
|
||||
|
||||
@@ -89,9 +89,9 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
key = "type_"
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
if (schema.get("type") == "string" and val != "enum") or (
|
||||
schema.get("type") not in ("number", "integer", "string")
|
||||
):
|
||||
if schema.get("type") == "string" and val != "enum":
|
||||
continue
|
||||
if schema.get("type") not in ("number", "integer", "string"):
|
||||
continue
|
||||
key = "format_"
|
||||
elif key == "items":
|
||||
@@ -100,11 +100,19 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
result[key] = val
|
||||
|
||||
if result.get("enum") and result.get("type_") != "STRING":
|
||||
# enum is only allowed for STRING type. This is safe as long as the schema
|
||||
# contains vol.Coerce for the respective type, for example:
|
||||
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
|
||||
result["type_"] = "STRING"
|
||||
result["enum"] = [str(item) for item in result["enum"]]
|
||||
|
||||
if result.get("type_") == "OBJECT" and not result.get("properties"):
|
||||
# An object with undefined properties is not supported by Gemini API.
|
||||
# Fallback to JSON string. This will probably fail for most tools that want it,
|
||||
# but we don't have a better fallback strategy so far.
|
||||
result["properties"] = {"json": {"type_": "STRING"}}
|
||||
result["required"] = []
|
||||
return result
|
||||
|
||||
|
||||
@@ -164,6 +172,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str] | Literal["*"]:
|
||||
@@ -177,6 +189,9 @@ class GoogleGenerativeAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -397,3 +412,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
parts.append(llm_api.api_prompt)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -18,3 +18,5 @@ FAN_MEDIUM_HIGH = "medium high"
|
||||
MAX_ERRORS = 2
|
||||
|
||||
TARGET_TEMPERATURE_STEP = 1
|
||||
|
||||
UPDATE_INTERVAL = 60
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from greeclimate.device import Device, DeviceInfo
|
||||
from greeclimate.discovery import Discovery, Listener
|
||||
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
|
||||
from greeclimate.network import Response
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
COORDINATORS,
|
||||
@@ -19,12 +23,13 @@ from .const import (
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
MAX_ERRORS,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages polling for state changes from the device."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: Device) -> None:
|
||||
@@ -34,28 +39,68 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}-{device.device_info.name}",
|
||||
update_interval=timedelta(seconds=60),
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self._error_count = 0
|
||||
self.device.add_handler(Response.DATA, self.device_state_updated)
|
||||
self.device.add_handler(Response.RESULT, self.device_state_updated)
|
||||
|
||||
async def _async_update_data(self):
|
||||
self._error_count: int = 0
|
||||
self._last_response_time: datetime = utcnow()
|
||||
self._last_error_time: datetime | None = None
|
||||
|
||||
def device_state_updated(self, *args: Any) -> None:
|
||||
"""Handle device state updates."""
|
||||
_LOGGER.debug("Device state updated: %s", json_dumps(args))
|
||||
self._error_count = 0
|
||||
self._last_response_time = utcnow()
|
||||
self.async_set_updated_data(self.device.raw_properties)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update the state of the device."""
|
||||
_LOGGER.debug(
|
||||
"Updating device state: %s, error count: %d", self.name, self._error_count
|
||||
)
|
||||
try:
|
||||
await self.device.update_state()
|
||||
except DeviceNotBoundError as error:
|
||||
raise UpdateFailed(f"Device {self.name} is unavailable") from error
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unavailable, device is not bound."
|
||||
) from error
|
||||
except DeviceTimeoutError as error:
|
||||
self._error_count += 1
|
||||
|
||||
# Under normal conditions GREE units timeout every once in a while
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
_LOGGER.warning(
|
||||
"Device is unavailable: %s (%s)",
|
||||
self.name,
|
||||
self.device.device_info,
|
||||
"Device %s is unavailable: %s", self.name, self.device.device_info
|
||||
)
|
||||
raise UpdateFailed(f"Device {self.name} is unavailable") from error
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unavailable, could not send update request"
|
||||
) from error
|
||||
else:
|
||||
# raise update failed if time for more than MAX_ERRORS has passed since last update
|
||||
now = utcnow()
|
||||
elapsed_success = now - self._last_response_time
|
||||
if self.update_interval and elapsed_success >= self.update_interval:
|
||||
if not self._last_error_time or (
|
||||
(now - self.update_interval) >= self._last_error_time
|
||||
):
|
||||
self._last_error_time = now
|
||||
self._error_count += 1
|
||||
|
||||
_LOGGER.warning(
|
||||
"Device %s is unresponsive for %s seconds",
|
||||
self.name,
|
||||
elapsed_success,
|
||||
)
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unresponsive for too long and now unavailable"
|
||||
)
|
||||
|
||||
return self.device.raw_properties
|
||||
|
||||
async def push_state_update(self):
|
||||
"""Send state updates to the physical device."""
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==1.4.6"]
|
||||
"requirements": ["greeclimate==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ from homeassistant.components.notify import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SERVICE,
|
||||
CONF_ACTION,
|
||||
CONF_ENTITIES,
|
||||
CONF_SERVICE,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -36,11 +37,37 @@ from .entity import GroupEntity
|
||||
|
||||
CONF_SERVICES = "services"
|
||||
|
||||
|
||||
def _backward_compat_schema(value: Any | None) -> Any:
|
||||
"""Backward compatibility for notify service schemas."""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
# `service` has been renamed to `action`
|
||||
if CONF_SERVICE in value:
|
||||
if CONF_ACTION in value:
|
||||
raise vol.Invalid(
|
||||
"Cannot specify both 'service' and 'action'. Please use 'action' only."
|
||||
)
|
||||
value[CONF_ACTION] = value.pop(CONF_SERVICE)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_SERVICES): vol.All(
|
||||
cv.ensure_list,
|
||||
[{vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict}],
|
||||
[
|
||||
vol.All(
|
||||
_backward_compat_schema,
|
||||
{
|
||||
vol.Required(CONF_ACTION): cv.slug,
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -88,7 +115,7 @@ class GroupNotifyPlatform(BaseNotificationService):
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
self.hass.services.async_call(
|
||||
DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True
|
||||
DOMAIN, entity[CONF_ACTION], sending_payload, blocking=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -327,14 +327,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_discharge_w",
|
||||
translation_key="tlx_battery_2_discharge_w",
|
||||
api_key="bdc1DischargePower",
|
||||
api_key="bdc2DischargePower",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_discharge_total",
|
||||
translation_key="tlx_battery_2_discharge_total",
|
||||
api_key="bdc1DischargeTotal",
|
||||
api_key="bdc2DischargeTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -376,14 +376,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_charge_w",
|
||||
translation_key="tlx_battery_2_charge_w",
|
||||
api_key="bdc1ChargePower",
|
||||
api_key="bdc2ChargePower",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_charge_total",
|
||||
translation_key="tlx_battery_2_charge_total",
|
||||
api_key="bdc1ChargeTotal",
|
||||
api_key="bdc2ChargeTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
|
||||
@@ -60,8 +60,11 @@
|
||||
"integration_not_found": {
|
||||
"title": "Integration {domain} not found",
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"issue_ignored": "Not existing integration {domain} ignored."
|
||||
},
|
||||
"step": {
|
||||
"remove_entries": {
|
||||
"init": {
|
||||
"title": "[%key:component::homeassistant::issues::integration_not_found::title%]",
|
||||
"description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
|
||||
"menu_options": {
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components import (
|
||||
sensor,
|
||||
)
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
||||
from homeassistant.components.media_player import (
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
@@ -167,9 +168,11 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
vol.Optional(
|
||||
CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE
|
||||
): cv.positive_int,
|
||||
vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(binary_sensor.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(
|
||||
[binary_sensor.DOMAIN, EVENT_DOMAIN]
|
||||
),
|
||||
vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain(
|
||||
binary_sensor.DOMAIN
|
||||
[binary_sensor.DOMAIN, EVENT_DOMAIN]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -845,21 +845,41 @@ class HKDevice:
|
||||
|
||||
async def async_update(self, now: datetime | None = None) -> None:
|
||||
"""Poll state of all entities attached to this bridge/accessory."""
|
||||
to_poll = self.pollable_characteristics
|
||||
accessories = self.entity_map.accessories
|
||||
|
||||
if (
|
||||
len(self.entity_map.accessories) == 1
|
||||
len(accessories) == 1
|
||||
and self.available
|
||||
and not (self.pollable_characteristics - self.watchable_characteristics)
|
||||
and not (to_poll - self.watchable_characteristics)
|
||||
and self.pairing.is_available
|
||||
and await self.pairing.controller.async_reachable(
|
||||
self.unique_id, timeout=5.0
|
||||
)
|
||||
):
|
||||
# If its a single accessory and all chars are watchable,
|
||||
# we don't need to poll.
|
||||
_LOGGER.debug("Accessory is reachable, skip polling: %s", self.unique_id)
|
||||
return
|
||||
# only poll the firmware version to keep the connection alive
|
||||
# https://github.com/home-assistant/core/issues/123412
|
||||
#
|
||||
# Firmware revision is used here since iOS does this to keep camera
|
||||
# connections alive, and the goal is to not regress
|
||||
# https://github.com/home-assistant/core/issues/116143
|
||||
# by polling characteristics that are not normally polled frequently
|
||||
# and may not be tested by the device vendor.
|
||||
#
|
||||
_LOGGER.debug(
|
||||
"Accessory is reachable, limiting poll to firmware version: %s",
|
||||
self.unique_id,
|
||||
)
|
||||
first_accessory = accessories[0]
|
||||
accessory_info = first_accessory.services.first(
|
||||
service_type=ServicesTypes.ACCESSORY_INFORMATION
|
||||
)
|
||||
assert accessory_info is not None
|
||||
firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
|
||||
to_poll = {(first_accessory.aid, firmware_iid)}
|
||||
|
||||
if not self.pollable_characteristics:
|
||||
if not to_poll:
|
||||
self.async_update_available_state()
|
||||
_LOGGER.debug(
|
||||
"HomeKit connection not polling any characteristics: %s", self.unique_id
|
||||
@@ -892,9 +912,7 @@ class HKDevice:
|
||||
_LOGGER.debug("Starting HomeKit device update: %s", self.unique_id)
|
||||
|
||||
try:
|
||||
new_values_dict = await self.get_characteristics(
|
||||
self.pollable_characteristics
|
||||
)
|
||||
new_values_dict = await self.get_characteristics(to_poll)
|
||||
except AccessoryNotFoundError:
|
||||
# Not only did the connection fail, but also the accessory is not
|
||||
# visible on the network.
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.1"],
|
||||
"requirements": ["aiohomekit==3.2.2"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==v6.1.1"],
|
||||
"requirements": ["python-homewizard-energy==v6.2.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeworks",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhomeworks"],
|
||||
"requirements": ["pyhomeworks==1.1.0"]
|
||||
"requirements": ["pyhomeworks==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ class HTML5NotificationService(BaseNotificationService):
|
||||
elif response.status_code > 399:
|
||||
_LOGGER.error(
|
||||
"There was an issue sending the notification %s: %s",
|
||||
response.status,
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,81 +3,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
|
||||
from aiohttp.web import FileResponse, Request, StreamResponse
|
||||
from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound
|
||||
from aiohttp.web_fileresponse import CONTENT_TYPES, FALLBACK_CONTENT_TYPE
|
||||
from aiohttp.web_urldispatcher import StaticResource
|
||||
from lru import LRU
|
||||
|
||||
from .const import KEY_HASS
|
||||
|
||||
CACHE_TIME: Final = 31 * 86400 # = 1 month
|
||||
CACHE_HEADER = f"public, max-age={CACHE_TIME}"
|
||||
CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER}
|
||||
PATH_CACHE: LRU[tuple[str, Path], tuple[Path | None, str | None]] = LRU(512)
|
||||
|
||||
|
||||
def _get_file_path(rel_url: str, directory: Path) -> Path | None:
|
||||
"""Return the path to file on disk or None."""
|
||||
filename = Path(rel_url)
|
||||
if filename.anchor:
|
||||
# rel_url is an absolute name like
|
||||
# /static/\\machine_name\c$ or /static/D:\path
|
||||
# where the static dir is totally different
|
||||
raise HTTPForbidden
|
||||
filepath: Path = directory.joinpath(filename).resolve()
|
||||
filepath.relative_to(directory)
|
||||
# on opening a dir, load its contents if allowed
|
||||
if filepath.is_dir():
|
||||
return None
|
||||
if filepath.is_file():
|
||||
return filepath
|
||||
raise FileNotFoundError
|
||||
CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER}
|
||||
RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512)
|
||||
|
||||
|
||||
class CachingStaticResource(StaticResource):
|
||||
"""Static Resource handler that will add cache headers."""
|
||||
|
||||
async def _handle(self, request: Request) -> StreamResponse:
|
||||
"""Return requested file from disk as a FileResponse."""
|
||||
"""Wrap base handler to cache file path resolution and content type guess."""
|
||||
rel_url = request.match_info["filename"]
|
||||
key = (rel_url, self._directory)
|
||||
if (filepath_content_type := PATH_CACHE.get(key)) is None:
|
||||
hass = request.app[KEY_HASS]
|
||||
try:
|
||||
filepath = await hass.async_add_executor_job(_get_file_path, *key)
|
||||
except (ValueError, FileNotFoundError) as error:
|
||||
# relatively safe
|
||||
raise HTTPNotFound from error
|
||||
except HTTPForbidden:
|
||||
# forbidden
|
||||
raise
|
||||
except Exception as error:
|
||||
# perm error or other kind!
|
||||
request.app.logger.exception("Unexpected exception")
|
||||
raise HTTPNotFound from error
|
||||
response: StreamResponse
|
||||
|
||||
content_type: str | None = None
|
||||
if filepath is not None:
|
||||
content_type = (mimetypes.guess_type(rel_url))[
|
||||
0
|
||||
] or "application/octet-stream"
|
||||
PATH_CACHE[key] = (filepath, content_type)
|
||||
if key in RESPONSE_CACHE:
|
||||
file_path, content_type = RESPONSE_CACHE[key]
|
||||
response = FileResponse(file_path, chunk_size=self._chunk_size)
|
||||
response.headers[CONTENT_TYPE] = content_type
|
||||
else:
|
||||
filepath, content_type = filepath_content_type
|
||||
|
||||
if filepath and content_type:
|
||||
return FileResponse(
|
||||
filepath,
|
||||
chunk_size=self._chunk_size,
|
||||
headers={
|
||||
hdrs.CACHE_CONTROL: CACHE_HEADER,
|
||||
hdrs.CONTENT_TYPE: content_type,
|
||||
},
|
||||
response = await super()._handle(request)
|
||||
if not isinstance(response, FileResponse):
|
||||
# Must be directory index; ignore caching
|
||||
return response
|
||||
file_path = response._path # noqa: SLF001
|
||||
response.content_type = (
|
||||
CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE
|
||||
)
|
||||
# Cache actual header after setter construction.
|
||||
content_type = response.headers[CONTENT_TYPE]
|
||||
RESPONSE_CACHE[key] = (file_path, content_type)
|
||||
|
||||
raise HTTPForbidden if filepath is None else HTTPNotFound
|
||||
response.headers[CACHE_CONTROL] = CACHE_HEADER
|
||||
return response
|
||||
|
||||
@@ -46,4 +46,8 @@ class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]):
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
|
||||
self.device_info = await self.device.get_device_info()
|
||||
try:
|
||||
self.device_info = await self.device.get_device_info()
|
||||
|
||||
except CommunicationError as e:
|
||||
raise UpdateFailed("Cannot connect to device") from e
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"jakim": "Jabatan Kemajuan Islam Malaysia (JAKIM)",
|
||||
"tunisia": "Tunisia",
|
||||
"algeria": "Algeria",
|
||||
"kemenag": "ementerian Agama Republik Indonesia",
|
||||
"kemenag": "Kementerian Agama Republik Indonesia",
|
||||
"morocco": "Morocco",
|
||||
"portugal": "Comunidade Islamica de Lisboa",
|
||||
"jordan": "Ministry of Awqaf, Islamic Affairs and Holy Places, Jordan",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.0.11"]
|
||||
"requirements": ["pyjvcprojector==1.0.12"]
|
||||
}
|
||||
|
||||
@@ -147,18 +147,10 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Start the KNX integration."""
|
||||
hass.data[DATA_HASS_CONFIG] = config
|
||||
conf: ConfigType | None = config.get(DOMAIN)
|
||||
|
||||
if conf is None:
|
||||
# If we have a config entry, setup is done by that config entry.
|
||||
# If there is no config entry, this should fail.
|
||||
return bool(hass.config_entries.async_entries(DOMAIN))
|
||||
|
||||
conf = dict(conf)
|
||||
hass.data[DATA_KNX_CONFIG] = conf
|
||||
if (conf := config.get(DOMAIN)) is not None:
|
||||
hass.data[DATA_KNX_CONFIG] = dict(conf)
|
||||
|
||||
register_knx_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import ATTR_COUNTER, ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import BinarySensorSchema
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity):
|
||||
class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity):
|
||||
"""Representation of a KNX binary sensor."""
|
||||
|
||||
_device: XknxBinarySensor
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class KNXButton(KnxEntity, ButtonEntity):
|
||||
class KNXButton(KnxYamlEntity, ButtonEntity):
|
||||
"""Representation of a KNX button."""
|
||||
|
||||
_device: XknxRawValue
|
||||
|
||||
@@ -5,7 +5,11 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import Climate as XknxClimate, ClimateMode as XknxClimateMode
|
||||
from xknx.devices import (
|
||||
Climate as XknxClimate,
|
||||
ClimateMode as XknxClimateMode,
|
||||
Device as XknxDevice,
|
||||
)
|
||||
from xknx.dpt.dpt_20 import HVACControllerMode
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -35,7 +39,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
PRESET_MODES,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import ClimateSchema
|
||||
|
||||
ATTR_COMMAND_VALUE = "command_value"
|
||||
@@ -133,7 +137,7 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
)
|
||||
|
||||
|
||||
class KNXClimate(KnxEntity, ClimateEntity):
|
||||
class KNXClimate(KnxYamlEntity, ClimateEntity):
|
||||
"""Representation of a KNX climate device."""
|
||||
|
||||
_device: XknxClimate
|
||||
@@ -241,12 +245,9 @@ class KNXClimate(KnxEntity, ClimateEntity):
|
||||
if self._device.supports_on_off and not self._device.is_on:
|
||||
return HVACMode.OFF
|
||||
if self._device.mode is not None and self._device.mode.supports_controller_mode:
|
||||
hvac_mode = CONTROLLER_MODES.get(
|
||||
return CONTROLLER_MODES.get(
|
||||
self._device.mode.controller_mode, self.default_hvac_mode
|
||||
)
|
||||
if hvac_mode is not HVACMode.OFF:
|
||||
self._last_hvac_mode = hvac_mode
|
||||
return hvac_mode
|
||||
return self.default_hvac_mode
|
||||
|
||||
@property
|
||||
@@ -261,11 +262,15 @@ class KNXClimate(KnxEntity, ClimateEntity):
|
||||
|
||||
if self._device.supports_on_off:
|
||||
if not ha_controller_modes:
|
||||
ha_controller_modes.append(self.default_hvac_mode)
|
||||
ha_controller_modes.append(self._last_hvac_mode)
|
||||
ha_controller_modes.append(HVACMode.OFF)
|
||||
|
||||
hvac_modes = list(set(filter(None, ha_controller_modes)))
|
||||
return hvac_modes if hvac_modes else [self.default_hvac_mode]
|
||||
return (
|
||||
hvac_modes
|
||||
if hvac_modes
|
||||
else [self.hvac_mode] # mode read-only -> fall back to only current mode
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
@@ -354,3 +359,13 @@ class KNXClimate(KnxEntity, ClimateEntity):
|
||||
self._device.mode.unregister_device_updated_cb(self.after_update_callback)
|
||||
self._device.mode.xknx.devices.async_remove(self._device.mode)
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
def after_update_callback(self, _device: XknxDevice) -> None:
|
||||
"""Call after device was updated."""
|
||||
if self._device.mode is not None and self._device.mode.supports_controller_mode:
|
||||
hvac_mode = CONTROLLER_MODES.get(
|
||||
self._device.mode.controller_mode, self.default_hvac_mode
|
||||
)
|
||||
if hvac_mode is not HVACMode.OFF:
|
||||
self._last_hvac_mode = hvac_mode
|
||||
super().after_update_callback(_device)
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import CoverSchema
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXCover(KnxEntity, CoverEntity):
|
||||
class KNXCover(KnxYamlEntity, CoverEntity):
|
||||
"""Representation of a KNX cover."""
|
||||
|
||||
_device: XknxCover
|
||||
|
||||
@@ -31,7 +31,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -61,7 +61,7 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXDateEntity(KnxEntity, DateEntity, RestoreEntity):
|
||||
class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
|
||||
"""Representation of a KNX date."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
@@ -32,7 +32,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -62,7 +62,7 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXDateTimeEntity(KnxEntity, DateTimeEntity, RestoreEntity):
|
||||
class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX datetime."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import FanSchema
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
@@ -39,7 +39,7 @@ async def async_setup_entry(
|
||||
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXFan(KnxEntity, FanEntity):
|
||||
class KNXFan(KnxYamlEntity, FanEntity):
|
||||
"""Representation of a KNX fan."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
@@ -2,30 +2,55 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from xknx.devices import Device as XknxDevice
|
||||
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import KNXModule
|
||||
|
||||
SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal.{{}}"
|
||||
from .storage.config_store import PlatformControllerBase
|
||||
|
||||
|
||||
class KnxEntity(Entity):
|
||||
class KnxUiEntityPlatformController(PlatformControllerBase):
|
||||
"""Class to manage dynamic adding and reloading of UI entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
knx_module: KNXModule,
|
||||
entity_platform: EntityPlatform,
|
||||
entity_class: type[KnxUiEntity],
|
||||
) -> None:
|
||||
"""Initialize the UI platform."""
|
||||
self._knx_module = knx_module
|
||||
self._entity_platform = entity_platform
|
||||
self._entity_class = entity_class
|
||||
|
||||
async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add a new UI entity."""
|
||||
await self._entity_platform.async_add_entities(
|
||||
[self._entity_class(self._knx_module, unique_id, config)]
|
||||
)
|
||||
|
||||
async def update_entity(
|
||||
self, entity_entry: RegistryEntry, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing UI entities configuration."""
|
||||
await self._entity_platform.async_remove_entity(entity_entry.entity_id)
|
||||
await self.create_entity(unique_id=entity_entry.unique_id, config=config)
|
||||
|
||||
|
||||
class _KnxEntityBase(Entity):
|
||||
"""Representation of a KNX entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
|
||||
"""Set up device."""
|
||||
self._knx_module = knx_module
|
||||
self._device = device
|
||||
_knx_module: KNXModule
|
||||
_device: XknxDevice
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -49,7 +74,7 @@ class KnxEntity(Entity):
|
||||
"""Store register state change callback and start device object."""
|
||||
self._device.register_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_add(self._device)
|
||||
# super call needed to have methods of mulit-inherited classes called
|
||||
# super call needed to have methods of multi-inherited classes called
|
||||
# eg. for restoring state (like _KNXSwitch)
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -59,19 +84,22 @@ class KnxEntity(Entity):
|
||||
self._device.xknx.devices.async_remove(self._device)
|
||||
|
||||
|
||||
class KnxUIEntity(KnxEntity):
|
||||
class KnxYamlEntity(_KnxEntityBase):
|
||||
"""Representation of a KNX entity configured from YAML."""
|
||||
|
||||
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
|
||||
"""Initialize the YAML entity."""
|
||||
self._knx_module = knx_module
|
||||
self._device = device
|
||||
|
||||
|
||||
class KnxUiEntity(_KnxEntityBase, ABC):
|
||||
"""Representation of a KNX UI entity."""
|
||||
|
||||
_attr_unique_id: str
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks when entity added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._knx_module.config_store.entities.add(self._attr_unique_id)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id),
|
||||
self.async_remove,
|
||||
)
|
||||
)
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize the UI entity."""
|
||||
|
||||
@@ -19,15 +19,18 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from . import KNXModule
|
||||
from .const import CONF_SYNC_STATE, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes
|
||||
from .knx_entity import KnxEntity, KnxUIEntity
|
||||
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .schema import LightSchema
|
||||
from .storage.const import (
|
||||
CONF_COLOR_TEMP_MAX,
|
||||
@@ -63,8 +66,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up light(s) for KNX platform."""
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.LIGHT,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiLight,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[KnxEntity] = []
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT):
|
||||
entities.extend(
|
||||
KnxYamlLight(knx_module, entity_config)
|
||||
@@ -78,13 +90,6 @@ async def async_setup_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def add_new_ui_light(unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add KNX entity at runtime."""
|
||||
async_add_entities([KnxUiLight(knx_module, unique_id, config)])
|
||||
|
||||
knx_module.config_store.async_add_entity[Platform.LIGHT] = add_new_ui_light
|
||||
|
||||
|
||||
def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight:
|
||||
"""Return a KNX Light device to be used within XKNX."""
|
||||
@@ -221,7 +226,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
|
||||
group_address_color_temp_state = None
|
||||
color_temperature_type = ColorTemperatureType.UINT_2_BYTE
|
||||
if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP):
|
||||
if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE:
|
||||
if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value:
|
||||
group_address_tunable_white = ga_color_temp[CONF_GA_WRITE]
|
||||
group_address_tunable_white_state = [
|
||||
ga_color_temp[CONF_GA_STATE],
|
||||
@@ -234,7 +239,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
|
||||
ga_color_temp[CONF_GA_STATE],
|
||||
*ga_color_temp[CONF_GA_PASSIVE],
|
||||
]
|
||||
if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT:
|
||||
if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value:
|
||||
color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE
|
||||
|
||||
_color_dpt = get_dpt(CONF_GA_COLOR)
|
||||
@@ -519,7 +524,7 @@ class _KnxLight(LightEntity):
|
||||
await self._device.set_off()
|
||||
|
||||
|
||||
class KnxYamlLight(_KnxLight, KnxEntity):
|
||||
class KnxYamlLight(_KnxLight, KnxYamlEntity):
|
||||
"""Representation of a KNX light."""
|
||||
|
||||
_device: XknxLight
|
||||
@@ -546,7 +551,7 @@ class KnxYamlLight(_KnxLight, KnxEntity):
|
||||
)
|
||||
|
||||
|
||||
class KnxUiLight(_KnxLight, KnxUIEntity):
|
||||
class KnxUiLight(_KnxLight, KnxUiEntity):
|
||||
"""Representation of a KNX light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -556,11 +561,9 @@ class KnxUiLight(_KnxLight, KnxUIEntity):
|
||||
self, knx_module: KNXModule, unique_id: str, config: ConfigType
|
||||
) -> None:
|
||||
"""Initialize of KNX light."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_ui_light(
|
||||
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
|
||||
),
|
||||
self._knx_module = knx_module
|
||||
self._device = _create_ui_light(
|
||||
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
|
||||
)
|
||||
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
|
||||
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==3.0.0",
|
||||
"xknx==3.1.0",
|
||||
"xknxproject==3.7.1",
|
||||
"knx-frontend==2024.7.25.204106"
|
||||
"knx-frontend==2024.8.9.225351"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
@@ -103,7 +103,7 @@ def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotific
|
||||
)
|
||||
|
||||
|
||||
class KNXNotify(KnxEntity, NotifyEntity):
|
||||
class KNXNotify(KnxYamlEntity, NotifyEntity):
|
||||
"""Representation of a KNX notification entity."""
|
||||
|
||||
_device: XknxNotification
|
||||
|
||||
@@ -30,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import NumberSchema
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
|
||||
)
|
||||
|
||||
|
||||
class KNXNumber(KnxEntity, RestoreNumber):
|
||||
class KNXNumber(KnxYamlEntity, RestoreNumber):
|
||||
"""Representation of a KNX number."""
|
||||
|
||||
_device: NumericValue
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SceneSchema
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||
async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXScene(KnxEntity, Scene):
|
||||
class KNXScene(KnxYamlEntity, Scene):
|
||||
"""Representation of a KNX scene."""
|
||||
|
||||
_device: XknxScene
|
||||
|
||||
@@ -30,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SelectSchema
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue:
|
||||
)
|
||||
|
||||
|
||||
class KNXSelect(KnxEntity, SelectEntity, RestoreEntity):
|
||||
class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
|
||||
"""Representation of a KNX select."""
|
||||
|
||||
_device: RawValue
|
||||
|
||||
@@ -35,7 +35,7 @@ from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from . import KNXModule
|
||||
from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SensorSchema
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
@@ -141,7 +141,7 @@ def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
|
||||
)
|
||||
|
||||
|
||||
class KNXSensor(KnxEntity, SensorEntity):
|
||||
class KNXSensor(KnxYamlEntity, SensorEntity):
|
||||
"""Representation of a KNX sensor."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""KNX entity configuration store."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import Any, Final, TypedDict
|
||||
|
||||
@@ -8,12 +8,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PLATFORM, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.ulid import ulid_now
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..knx_entity import SIGNAL_ENTITY_REMOVE
|
||||
from .const import CONF_DATA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -33,6 +31,20 @@ class KNXConfigStoreModel(TypedDict):
|
||||
entities: KNXEntityStoreModel
|
||||
|
||||
|
||||
class PlatformControllerBase(ABC):
|
||||
"""Entity platform controller base class."""
|
||||
|
||||
@abstractmethod
|
||||
async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Create a new entity."""
|
||||
|
||||
@abstractmethod
|
||||
async def update_entity(
|
||||
self, entity_entry: er.RegistryEntry, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing entities configuration."""
|
||||
|
||||
|
||||
class KNXConfigStore:
|
||||
"""Manage KNX config store data."""
|
||||
|
||||
@@ -46,12 +58,7 @@ class KNXConfigStore:
|
||||
self.config_entry = config_entry
|
||||
self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self.data = KNXConfigStoreModel(entities={})
|
||||
|
||||
# entities and async_add_entity are filled by platform / entity setups
|
||||
self.entities: set[str] = set() # unique_id as values
|
||||
self.async_add_entity: dict[
|
||||
Platform, Callable[[str, dict[str, Any]], None]
|
||||
] = {}
|
||||
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
|
||||
|
||||
async def load_data(self) -> None:
|
||||
"""Load config store data from storage."""
|
||||
@@ -62,14 +69,19 @@ class KNXConfigStore:
|
||||
len(self.data["entities"]),
|
||||
)
|
||||
|
||||
def add_platform(
|
||||
self, platform: Platform, controller: PlatformControllerBase
|
||||
) -> None:
|
||||
"""Add platform controller."""
|
||||
self._platform_controllers[platform] = controller
|
||||
|
||||
async def create_entity(
|
||||
self, platform: Platform, data: dict[str, Any]
|
||||
) -> str | None:
|
||||
"""Create a new entity."""
|
||||
if platform not in self.async_add_entity:
|
||||
raise ConfigStoreException(f"Entity platform not ready: {platform}")
|
||||
platform_controller = self._platform_controllers[platform]
|
||||
unique_id = f"knx_es_{ulid_now()}"
|
||||
self.async_add_entity[platform](unique_id, data)
|
||||
await platform_controller.create_entity(unique_id, data)
|
||||
# store data after entity was added to be sure config didn't raise exceptions
|
||||
self.data["entities"].setdefault(platform, {})[unique_id] = data
|
||||
await self._store.async_save(self.data)
|
||||
@@ -95,8 +107,7 @@ class KNXConfigStore:
|
||||
self, platform: Platform, entity_id: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing entity."""
|
||||
if platform not in self.async_add_entity:
|
||||
raise ConfigStoreException(f"Entity platform not ready: {platform}")
|
||||
platform_controller = self._platform_controllers[platform]
|
||||
entity_registry = er.async_get(self.hass)
|
||||
if (entry := entity_registry.async_get(entity_id)) is None:
|
||||
raise ConfigStoreException(f"Entity not found: {entity_id}")
|
||||
@@ -108,8 +119,7 @@ class KNXConfigStore:
|
||||
raise ConfigStoreException(
|
||||
f"Entity not found in storage: {entity_id} - {unique_id}"
|
||||
)
|
||||
async_dispatcher_send(self.hass, SIGNAL_ENTITY_REMOVE.format(unique_id))
|
||||
self.async_add_entity[platform](unique_id, data)
|
||||
await platform_controller.update_entity(entry, data)
|
||||
# store data after entity is added to make sure config doesn't raise exceptions
|
||||
self.data["entities"][platform][unique_id] = data
|
||||
await self._store.async_save(self.data)
|
||||
@@ -125,23 +135,21 @@ class KNXConfigStore:
|
||||
raise ConfigStoreException(
|
||||
f"Entity not found in {entry.domain}: {entry.unique_id}"
|
||||
) from err
|
||||
try:
|
||||
self.entities.remove(entry.unique_id)
|
||||
except KeyError:
|
||||
_LOGGER.warning("Entity not initialized when deleted: %s", entity_id)
|
||||
entity_registry.async_remove(entity_id)
|
||||
await self._store.async_save(self.data)
|
||||
|
||||
def get_entity_entries(self) -> list[er.RegistryEntry]:
|
||||
"""Get entity_ids of all configured entities by platform."""
|
||||
"""Get entity_ids of all UI configured entities."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
unique_ids = {
|
||||
uid for platform in self.data["entities"].values() for uid in platform
|
||||
}
|
||||
return [
|
||||
registry_entry
|
||||
for registry_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, self.config_entry.entry_id
|
||||
)
|
||||
if registry_entry.unique_id in self.entities
|
||||
if registry_entry.unique_id in unique_ids
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -32,7 +35,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity, KnxUIEntity
|
||||
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .schema import SwitchSchema
|
||||
from .storage.const import (
|
||||
CONF_DEVICE_INFO,
|
||||
@@ -51,8 +54,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up switch(es) for KNX platform."""
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.SWITCH,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiSwitch,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[KnxEntity] = []
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH):
|
||||
entities.extend(
|
||||
KnxYamlSwitch(knx_module, entity_config)
|
||||
@@ -66,13 +78,6 @@ async def async_setup_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add KNX entity at runtime."""
|
||||
async_add_entities([KnxUiSwitch(knx_module, unique_id, config)])
|
||||
|
||||
knx_module.config_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch
|
||||
|
||||
|
||||
class _KnxSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Base class for a KNX switch."""
|
||||
@@ -102,7 +107,7 @@ class _KnxSwitch(SwitchEntity, RestoreEntity):
|
||||
await self._device.set_off()
|
||||
|
||||
|
||||
class KnxYamlSwitch(_KnxSwitch, KnxEntity):
|
||||
class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
|
||||
"""Representation of a KNX switch configured from YAML."""
|
||||
|
||||
_device: XknxSwitch
|
||||
@@ -125,7 +130,7 @@ class KnxYamlSwitch(_KnxSwitch, KnxEntity):
|
||||
self._attr_unique_id = str(self._device.switch.group_address)
|
||||
|
||||
|
||||
class KnxUiSwitch(_KnxSwitch, KnxUIEntity):
|
||||
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
|
||||
"""Representation of a KNX switch configured from UI."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -134,21 +139,19 @@ class KnxUiSwitch(_KnxSwitch, KnxUIEntity):
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize of KNX switch."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxSwitch(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
|
||||
group_address_state=[
|
||||
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
|
||||
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
|
||||
],
|
||||
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
|
||||
sync_state=config[DOMAIN][CONF_SYNC_STATE],
|
||||
invert=config[DOMAIN][CONF_INVERT],
|
||||
),
|
||||
"""Initialize KNX switch."""
|
||||
self._knx_module = knx_module
|
||||
self._device = XknxSwitch(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
|
||||
group_address_state=[
|
||||
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
|
||||
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
|
||||
],
|
||||
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
|
||||
sync_state=config[DOMAIN][CONF_SYNC_STATE],
|
||||
invert=config[DOMAIN][CONF_INVERT],
|
||||
)
|
||||
self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY]
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
@@ -30,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -57,7 +57,7 @@ def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
|
||||
)
|
||||
|
||||
|
||||
class KNXText(KnxEntity, TextEntity, RestoreEntity):
|
||||
class KNXText(KnxYamlEntity, TextEntity, RestoreEntity):
|
||||
"""Representation of a KNX text."""
|
||||
|
||||
_device: XknxNotification
|
||||
|
||||
@@ -31,7 +31,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -61,7 +61,7 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXTimeEntity(KnxEntity, TimeEntity, RestoreEntity):
|
||||
class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX time."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import WeatherSchema
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather:
|
||||
)
|
||||
|
||||
|
||||
class KNXWeather(KnxEntity, WeatherEntity):
|
||||
class KNXWeather(KnxYamlEntity, WeatherEntity):
|
||||
"""Representation of a KNX weather device."""
|
||||
|
||||
_device: XknxWeather
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["lacrosse_view"],
|
||||
"requirements": ["lacrosse-view==1.0.1"]
|
||||
"requirements": ["lacrosse-view==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"],
|
||||
"requirements": ["pypck==0.7.17"]
|
||||
"requirements": ["pypck==0.7.20"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pylutron_caseta"],
|
||||
"requirements": ["pylutron-caseta==0.20.0"],
|
||||
"requirements": ["pylutron-caseta==0.21.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_lutron._tcp.local.",
|
||||
|
||||
@@ -277,4 +277,15 @@ class MadvrSensor(MadVREntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | str | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
val = self.entity_description.value_fn(self.coordinator)
|
||||
# check if sensor is enum
|
||||
if self.entity_description.device_class == SensorDeviceClass.ENUM:
|
||||
if (
|
||||
self.entity_description.options
|
||||
and val in self.entity_description.options
|
||||
):
|
||||
return val
|
||||
# return None for values that are not in the options
|
||||
return None
|
||||
|
||||
return val
|
||||
|
||||
@@ -51,17 +51,19 @@ DEFAULT_TRANSITION = 0.2
|
||||
# hw version (attributeKey 0/40/8)
|
||||
# sw version (attributeKey 0/40/10)
|
||||
TRANSITION_BLOCKLIST = (
|
||||
(4488, 514, "1.0", "1.0.0"),
|
||||
(4488, 260, "1.0", "1.0.0"),
|
||||
(5010, 769, "3.0", "1.0.0"),
|
||||
(4999, 25057, "1.0", "27.0"),
|
||||
(4448, 36866, "V1", "V1.0.0.5"),
|
||||
(5009, 514, "1.0", "1.0.0"),
|
||||
(4107, 8475, "v1.0", "v1.0"),
|
||||
(4107, 8550, "v1.0", "v1.0"),
|
||||
(4107, 8551, "v1.0", "v1.0"),
|
||||
(4107, 8656, "v1.0", "v1.0"),
|
||||
(4107, 8571, "v1.0", "v1.0"),
|
||||
(4107, 8656, "v1.0", "v1.0"),
|
||||
(4448, 36866, "V1", "V1.0.0.5"),
|
||||
(4456, 1011, "1.0.0", "2.00.00"),
|
||||
(4488, 260, "1.0", "1.0.0"),
|
||||
(4488, 514, "1.0", "1.0.0"),
|
||||
(4999, 24875, "1.0", "27.0"),
|
||||
(4999, 25057, "1.0", "27.0"),
|
||||
(5009, 514, "1.0", "1.0.0"),
|
||||
(5010, 769, "3.0", "1.0.0"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ type SelectCluster = (
|
||||
| clusters.RvcRunMode
|
||||
| clusters.RvcCleanMode
|
||||
| clusters.DishwasherMode
|
||||
| clusters.MicrowaveOvenMode
|
||||
| clusters.EnergyEvseMode
|
||||
| clusters.DeviceEnergyManagementMode
|
||||
)
|
||||
@@ -199,18 +198,6 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.DishwasherMode.Attributes.SupportedModes,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SELECT,
|
||||
entity_description=MatterSelectEntityDescription(
|
||||
key="MatterMicrowaveOvenMode",
|
||||
translation_key="mode",
|
||||
),
|
||||
entity_class=MatterModeSelectEntity,
|
||||
required_attributes=(
|
||||
clusters.MicrowaveOvenMode.Attributes.CurrentMode,
|
||||
clusters.MicrowaveOvenMode.Attributes.SupportedModes,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SELECT,
|
||||
entity_description=MatterSelectEntityDescription(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mealie",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["aiomealie==0.8.0"]
|
||||
"requirements": ["aiomealie==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp==2024.07.16"],
|
||||
"requirements": ["yt-dlp==2024.08.06"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mfi",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["mficlient"],
|
||||
"requirements": ["mficlient==0.3.0"]
|
||||
"requirements": ["mficlient==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -124,12 +124,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
|
||||
if (
|
||||
CONF_CLOUDHOOK_URL not in entry.data
|
||||
and cloud.async_active_subscription(hass)
|
||||
and cloud.async_is_connected(hass)
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
if cloud.async_is_logged_in(hass):
|
||||
if (
|
||||
CONF_CLOUDHOOK_URL not in entry.data
|
||||
and cloud.async_active_subscription(hass)
|
||||
and cloud.async_is_connected(hass)
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
elif CONF_CLOUDHOOK_URL in entry.data:
|
||||
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
|
||||
data = dict(entry.data)
|
||||
data.pop(CONF_CLOUDHOOK_URL)
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
|
||||
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/monzo",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["monzopy==1.3.0"]
|
||||
"requirements": ["monzopy==1.3.2"]
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ async def async_setup_platform(
|
||||
)
|
||||
if (
|
||||
result["type"] is FlowResultType.CREATE_ENTRY
|
||||
or result["reason"] == "single_instance_allowed"
|
||||
or result["reason"] == "already_configured"
|
||||
):
|
||||
async_create_issue(
|
||||
hass,
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["google-nest-sdm==4.0.5"]
|
||||
"requirements": ["google-nest-sdm==4.0.6"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nextbus",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["py_nextbus"],
|
||||
"requirements": ["py-nextbusnext==2.0.3"]
|
||||
"requirements": ["py-nextbusnext==2.0.4"]
|
||||
}
|
||||
|
||||
@@ -346,9 +346,5 @@ class OllamaConversationEntity(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
if entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
else:
|
||||
self._attr_supported_features = conversation.ConversationEntityFeature(0)
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -328,9 +328,5 @@ class OpenAIConversationEntity(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
if entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
else:
|
||||
self._attr_supported_features = conversation.ConversationEntityFeature(0)
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from pyopenweathermap import OWMClient
|
||||
from pyopenweathermap import create_owm_client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -33,6 +33,7 @@ class OpenweathermapData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
name: str
|
||||
mode: str
|
||||
coordinator: WeatherUpdateCoordinator
|
||||
|
||||
|
||||
@@ -52,7 +53,7 @@ async def async_setup_entry(
|
||||
else:
|
||||
async_delete_issue(hass, entry.entry_id)
|
||||
|
||||
owm_client = OWMClient(api_key, mode, lang=language)
|
||||
owm_client = create_owm_client(api_key, mode, lang=language)
|
||||
weather_coordinator = WeatherUpdateCoordinator(
|
||||
owm_client, latitude, longitude, hass
|
||||
)
|
||||
@@ -61,7 +62,7 @@ async def async_setup_entry(
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
entry.runtime_data = OpenweathermapData(name, weather_coordinator)
|
||||
entry.runtime_data = OpenweathermapData(name, mode, weather_coordinator)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -58,10 +58,17 @@ FORECAST_MODE_DAILY = "daily"
|
||||
FORECAST_MODE_FREE_DAILY = "freedaily"
|
||||
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
|
||||
FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
|
||||
OWM_MODE_V25 = "v2.5"
|
||||
OWM_MODE_FREE_CURRENT = "current"
|
||||
OWM_MODE_FREE_FORECAST = "forecast"
|
||||
OWM_MODE_V30 = "v3.0"
|
||||
OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
|
||||
DEFAULT_OWM_MODE = OWM_MODE_V30
|
||||
OWM_MODE_V25 = "v2.5"
|
||||
OWM_MODES = [
|
||||
OWM_MODE_FREE_CURRENT,
|
||||
OWM_MODE_FREE_FORECAST,
|
||||
OWM_MODE_V30,
|
||||
OWM_MODE_V25,
|
||||
]
|
||||
DEFAULT_OWM_MODE = OWM_MODE_FREE_CURRENT
|
||||
|
||||
LANGUAGES = [
|
||||
"af",
|
||||
|
||||
@@ -86,8 +86,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Format the weather response correctly."""
|
||||
_LOGGER.debug("OWM weather response: %s", weather_report)
|
||||
|
||||
current_weather = (
|
||||
self._get_current_weather_data(weather_report.current)
|
||||
if weather_report.current is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current),
|
||||
ATTR_API_CURRENT: current_weather,
|
||||
ATTR_API_HOURLY_FORECAST: [
|
||||
self._get_hourly_forecast_weather_data(item)
|
||||
for item in weather_report.hourly_forecast
|
||||
@@ -122,6 +128,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
}
|
||||
|
||||
def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
|
||||
uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
|
||||
|
||||
return Forecast(
|
||||
datetime=forecast.date_time.isoformat(),
|
||||
condition=self._get_condition(forecast.condition.id),
|
||||
@@ -134,12 +142,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
wind_speed=forecast.wind_speed,
|
||||
native_wind_gust_speed=forecast.wind_gust,
|
||||
wind_bearing=forecast.wind_bearing,
|
||||
uv_index=float(forecast.uv_index),
|
||||
uv_index=uv_index,
|
||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||
precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
|
||||
)
|
||||
|
||||
def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
|
||||
uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
|
||||
|
||||
return Forecast(
|
||||
datetime=forecast.date_time.isoformat(),
|
||||
condition=self._get_condition(forecast.condition.id),
|
||||
@@ -153,7 +163,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
wind_speed=forecast.wind_speed,
|
||||
native_wind_gust_speed=forecast.wind_gust,
|
||||
wind_bearing=forecast.wind_bearing,
|
||||
uv_index=float(forecast.uv_index),
|
||||
uv_index=uv_index,
|
||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||
precipitation=round(forecast.rain + forecast.snow, 2),
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyopenweathermap"],
|
||||
"requirements": ["pyopenweathermap==0.0.9"]
|
||||
"requirements": ["pyopenweathermap==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import (
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -47,6 +48,7 @@ from .const import (
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
OWM_MODE_FREE_FORECAST,
|
||||
)
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
@@ -161,16 +163,23 @@ async def async_setup_entry(
|
||||
name = domain_data.name
|
||||
weather_coordinator = domain_data.coordinator
|
||||
|
||||
entities: list[AbstractOpenWeatherMapSensor] = [
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
f"{config_entry.unique_id}-{description.key}",
|
||||
description,
|
||||
weather_coordinator,
|
||||
if domain_data.mode == OWM_MODE_FREE_FORECAST:
|
||||
entity_registry = er.async_get(hass)
|
||||
entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry.entry_id
|
||||
)
|
||||
for entry in entries:
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
else:
|
||||
async_add_entities(
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
f"{config_entry.unique_id}-{description.key}",
|
||||
description,
|
||||
weather_coordinator,
|
||||
)
|
||||
for description in WEATHER_SENSOR_TYPES
|
||||
)
|
||||
for description in WEATHER_SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbstractOpenWeatherMapSensor(SensorEntity):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user