mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 15:17:35 +00:00
2024.8.1 (#123544)
This commit is contained in:
commit
ae4fc9504a
@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["airgradient==0.7.1"],
|
"requirements": ["airgradient==0.8.0"],
|
||||||
"zeroconf": ["_airgradient._tcp.local."]
|
"zeroconf": ["_airgradient._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -317,21 +317,24 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
await self.async_update_status()
|
await self.async_update_status()
|
||||||
|
|
||||||
except (TimeoutError, ClientError):
|
except (TimeoutError, ClientError):
|
||||||
_LOGGER.error("Node %s:%s is offline, retrying later", self.name, self.port)
|
_LOGGER.error("Node %s:%s is offline, retrying later", self.host, self.port)
|
||||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||||
self.start_polling()
|
self.start_polling()
|
||||||
|
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
_LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port)
|
_LOGGER.debug("Stopping the polling of node %s:%s", self.host, self.port)
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
|
_LOGGER.exception("Unexpected error in %s:%s", self.host, self.port)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Start the polling task."""
|
"""Start the polling task."""
|
||||||
await super().async_added_to_hass()
|
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:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Stop the polling task."""
|
"""Stop the polling task."""
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["dio_chacon_api"],
|
"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
|
from datetime import timedelta
|
||||||
import logging
|
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 coinbase.wallet.error import AuthenticationError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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 homeassistant.util import Throttle
|
||||||
|
|
||||||
from .const import (
|
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_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_CURRENCIES,
|
||||||
CONF_EXCHANGE_BASE,
|
CONF_EXCHANGE_BASE,
|
||||||
CONF_EXCHANGE_RATES,
|
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:
|
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
|
||||||
"""Create and update a Coinbase Data instance."""
|
"""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")
|
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
|
||||||
instance = CoinbaseData(client, base_rate)
|
instance = CoinbaseData(client, base_rate, version)
|
||||||
instance.update()
|
instance.update()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@ -86,42 +110,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
|
|||||||
registry.async_remove(entity.entity_id)
|
registry.async_remove(entity.entity_id)
|
||||||
|
|
||||||
|
|
||||||
def get_accounts(client):
|
def get_accounts(client, version):
|
||||||
"""Handle paginated accounts."""
|
"""Handle paginated accounts."""
|
||||||
response = client.get_accounts()
|
response = client.get_accounts()
|
||||||
accounts = response[API_ACCOUNTS_DATA]
|
if version == "v2":
|
||||||
next_starting_after = response.pagination.next_starting_after
|
accounts = response[API_DATA]
|
||||||
|
|
||||||
while next_starting_after:
|
|
||||||
response = client.get_accounts(starting_after=next_starting_after)
|
|
||||||
accounts += response[API_ACCOUNTS_DATA]
|
|
||||||
next_starting_after = response.pagination.next_starting_after
|
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:
|
class CoinbaseData:
|
||||||
"""Get the latest data and update the states."""
|
"""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."""
|
"""Init the coinbase data object."""
|
||||||
|
|
||||||
self.client = client
|
self.client = client
|
||||||
self.accounts = None
|
self.accounts = None
|
||||||
self.exchange_base = exchange_base
|
self.exchange_base = exchange_base
|
||||||
self.exchange_rates = None
|
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)
|
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Get the latest data from coinbase."""
|
"""Get the latest data from coinbase."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.accounts = get_accounts(self.client)
|
self.accounts = get_accounts(self.client, self.api_version)
|
||||||
self.exchange_rates = self.client.get_exchange_rates(
|
if self.api_version == "v2":
|
||||||
currency=self.exchange_base
|
self.exchange_rates = self.client.get_exchange_rates(
|
||||||
)
|
currency=self.exchange_base
|
||||||
except AuthenticationError as coinbase_error:
|
)
|
||||||
|
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(
|
_LOGGER.error(
|
||||||
"Authentication error connecting to coinbase: %s", coinbase_error
|
"Authentication error connecting to coinbase: %s", coinbase_error
|
||||||
)
|
)
|
||||||
|
@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
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
|
from coinbase.wallet.error import AuthenticationError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@ -15,18 +17,17 @@ from homeassistant.config_entries import (
|
|||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
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.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
from . import get_accounts
|
from . import get_accounts
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ACCOUNT_IS_VAULT,
|
||||||
API_ACCOUNT_CURRENCY,
|
API_ACCOUNT_CURRENCY,
|
||||||
API_ACCOUNT_CURRENCY_CODE,
|
API_DATA,
|
||||||
API_RATES,
|
API_RATES,
|
||||||
API_RESOURCE_TYPE,
|
|
||||||
API_TYPE_VAULT,
|
|
||||||
CONF_CURRENCIES,
|
CONF_CURRENCIES,
|
||||||
CONF_EXCHANGE_BASE,
|
CONF_EXCHANGE_BASE,
|
||||||
CONF_EXCHANGE_PRECISION,
|
CONF_EXCHANGE_PRECISION,
|
||||||
@ -49,8 +50,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
|||||||
|
|
||||||
def get_user_from_client(api_key, api_token):
|
def get_user_from_client(api_key, api_token):
|
||||||
"""Get the user name from Coinbase API credentials."""
|
"""Get the user name from Coinbase API credentials."""
|
||||||
client = Client(api_key, api_token)
|
if "organizations" not in api_key:
|
||||||
return client.get_current_user()
|
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):
|
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(
|
user = await hass.async_add_executor_job(
|
||||||
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
|
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
|
||||||
)
|
)
|
||||||
except AuthenticationError as error:
|
except (AuthenticationError, HTTPError) as error:
|
||||||
if "api key" in str(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")
|
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
|
||||||
raise InvalidKey from error
|
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(
|
_LOGGER.debug(
|
||||||
"Coinbase rejected API credentials due to an invalid API secret"
|
"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
|
raise InvalidAuth from error
|
||||||
except ConnectionError as error:
|
except ConnectionError as error:
|
||||||
raise CannotConnect from error
|
raise CannotConnect from error
|
||||||
|
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
|
||||||
return {"title": user["name"]}
|
return {"title": user, "api_version": api_version}
|
||||||
|
|
||||||
|
|
||||||
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
|
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
|
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 = [
|
accounts_currencies = [
|
||||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
account[API_ACCOUNT_CURRENCY]
|
||||||
for account in accounts
|
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:
|
if CONF_CURRENCIES in options:
|
||||||
for currency in options[CONF_CURRENCIES]:
|
for currency in options[CONF_CURRENCIES]:
|
||||||
if currency not in accounts_currencies:
|
if currency not in accounts_currencies:
|
||||||
@ -134,6 +146,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
else:
|
||||||
|
user_input[CONF_API_VERSION] = info["api_version"]
|
||||||
return self.async_create_entry(title=info["title"], data=user_input)
|
return self.async_create_entry(title=info["title"], data=user_input)
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Constants used for Coinbase."""
|
"""Constants used for Coinbase."""
|
||||||
|
|
||||||
|
ACCOUNT_IS_VAULT = "is_vault"
|
||||||
|
|
||||||
CONF_CURRENCIES = "account_balance_currencies"
|
CONF_CURRENCIES = "account_balance_currencies"
|
||||||
CONF_EXCHANGE_BASE = "exchange_base"
|
CONF_EXCHANGE_BASE = "exchange_base"
|
||||||
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
|
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
|
||||||
@ -10,18 +12,25 @@ DOMAIN = "coinbase"
|
|||||||
|
|
||||||
# Constants for data returned by Coinbase API
|
# Constants for data returned by Coinbase API
|
||||||
API_ACCOUNT_AMOUNT = "amount"
|
API_ACCOUNT_AMOUNT = "amount"
|
||||||
|
API_ACCOUNT_AVALIABLE = "available_balance"
|
||||||
API_ACCOUNT_BALANCE = "balance"
|
API_ACCOUNT_BALANCE = "balance"
|
||||||
API_ACCOUNT_CURRENCY = "currency"
|
API_ACCOUNT_CURRENCY = "currency"
|
||||||
API_ACCOUNT_CURRENCY_CODE = "code"
|
API_ACCOUNT_CURRENCY_CODE = "code"
|
||||||
|
API_ACCOUNT_HOLD = "hold"
|
||||||
API_ACCOUNT_ID = "id"
|
API_ACCOUNT_ID = "id"
|
||||||
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
||||||
API_ACCOUNT_NAME = "name"
|
API_ACCOUNT_NAME = "name"
|
||||||
API_ACCOUNTS_DATA = "data"
|
API_ACCOUNT_VALUE = "value"
|
||||||
|
API_ACCOUNTS = "accounts"
|
||||||
|
API_DATA = "data"
|
||||||
API_RATES = "rates"
|
API_RATES = "rates"
|
||||||
|
API_RATES_CURRENCY = "currency"
|
||||||
API_RESOURCE_PATH = "resource_path"
|
API_RESOURCE_PATH = "resource_path"
|
||||||
API_RESOURCE_TYPE = "type"
|
API_RESOURCE_TYPE = "type"
|
||||||
API_TYPE_VAULT = "vault"
|
API_TYPE_VAULT = "vault"
|
||||||
API_USD = "USD"
|
API_USD = "USD"
|
||||||
|
API_V3_ACCOUNT_ID = "uuid"
|
||||||
|
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
|
||||||
|
|
||||||
WALLETS = {
|
WALLETS = {
|
||||||
"1INCH": "1INCH",
|
"1INCH": "1INCH",
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/coinbase",
|
"documentation": "https://www.home-assistant.io/integrations/coinbase",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["coinbase"],
|
"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 . import CoinbaseData
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ACCOUNT_IS_VAULT,
|
||||||
API_ACCOUNT_AMOUNT,
|
API_ACCOUNT_AMOUNT,
|
||||||
API_ACCOUNT_BALANCE,
|
|
||||||
API_ACCOUNT_CURRENCY,
|
API_ACCOUNT_CURRENCY,
|
||||||
API_ACCOUNT_CURRENCY_CODE,
|
|
||||||
API_ACCOUNT_ID,
|
API_ACCOUNT_ID,
|
||||||
API_ACCOUNT_NAME,
|
API_ACCOUNT_NAME,
|
||||||
API_RATES,
|
API_RATES,
|
||||||
API_RESOURCE_TYPE,
|
|
||||||
API_TYPE_VAULT,
|
|
||||||
CONF_CURRENCIES,
|
CONF_CURRENCIES,
|
||||||
CONF_EXCHANGE_PRECISION,
|
CONF_EXCHANGE_PRECISION,
|
||||||
CONF_EXCHANGE_PRECISION_DEFAULT,
|
CONF_EXCHANGE_PRECISION_DEFAULT,
|
||||||
@ -31,6 +28,7 @@ from .const import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
ATTR_NATIVE_BALANCE = "Balance in native currency"
|
ATTR_NATIVE_BALANCE = "Balance in native currency"
|
||||||
|
ATTR_API_VERSION = "API Version"
|
||||||
|
|
||||||
CURRENCY_ICONS = {
|
CURRENCY_ICONS = {
|
||||||
"BTC": "mdi:currency-btc",
|
"BTC": "mdi:currency-btc",
|
||||||
@ -56,9 +54,9 @@ async def async_setup_entry(
|
|||||||
entities: list[SensorEntity] = []
|
entities: list[SensorEntity] = []
|
||||||
|
|
||||||
provided_currencies: list[str] = [
|
provided_currencies: list[str] = [
|
||||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
account[API_ACCOUNT_CURRENCY]
|
||||||
for account in instance.accounts
|
for account in instance.accounts
|
||||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
if not account[ACCOUNT_IS_VAULT]
|
||||||
]
|
]
|
||||||
|
|
||||||
desired_currencies: list[str] = []
|
desired_currencies: list[str] = []
|
||||||
@ -73,6 +71,11 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for currency in desired_currencies:
|
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:
|
if currency not in provided_currencies:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
(
|
(
|
||||||
@ -85,12 +88,17 @@ async def async_setup_entry(
|
|||||||
entities.append(AccountSensor(instance, currency))
|
entities.append(AccountSensor(instance, currency))
|
||||||
|
|
||||||
if CONF_EXCHANGE_RATES in config_entry.options:
|
if CONF_EXCHANGE_RATES in config_entry.options:
|
||||||
entities.extend(
|
for rate in config_entry.options[CONF_EXCHANGE_RATES]:
|
||||||
ExchangeRateSensor(
|
_LOGGER.debug(
|
||||||
instance, rate, exchange_base_currency, exchange_precision
|
"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)
|
async_add_entities(entities)
|
||||||
|
|
||||||
@ -105,26 +113,21 @@ class AccountSensor(SensorEntity):
|
|||||||
self._coinbase_data = coinbase_data
|
self._coinbase_data = coinbase_data
|
||||||
self._currency = currency
|
self._currency = currency
|
||||||
for account in coinbase_data.accounts:
|
for account in coinbase_data.accounts:
|
||||||
if (
|
if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
|
||||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
|
|
||||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
||||||
self._attr_unique_id = (
|
self._attr_unique_id = (
|
||||||
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
|
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_value = account[API_ACCOUNT_AMOUNT]
|
||||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
|
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
|
||||||
API_ACCOUNT_CURRENCY_CODE
|
|
||||||
]
|
|
||||||
self._attr_icon = CURRENCY_ICONS.get(
|
self._attr_icon = CURRENCY_ICONS.get(
|
||||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
|
account[API_ACCOUNT_CURRENCY],
|
||||||
DEFAULT_COIN_ICON,
|
DEFAULT_COIN_ICON,
|
||||||
)
|
)
|
||||||
self._native_balance = round(
|
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]),
|
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
@ -144,21 +147,26 @@ class AccountSensor(SensorEntity):
|
|||||||
"""Return the state attributes of the sensor."""
|
"""Return the state attributes of the sensor."""
|
||||||
return {
|
return {
|
||||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
||||||
|
ATTR_API_VERSION: self._coinbase_data.api_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Get the latest state of the sensor."""
|
"""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()
|
self._coinbase_data.update()
|
||||||
for account in self._coinbase_data.accounts:
|
for account in self._coinbase_data.accounts:
|
||||||
if (
|
if (
|
||||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
account[API_ACCOUNT_CURRENCY] != self._currency
|
||||||
!= self._currency
|
or account[ACCOUNT_IS_VAULT]
|
||||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
|
||||||
self._native_balance = round(
|
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]),
|
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
@ -202,8 +210,13 @@ class ExchangeRateSensor(SensorEntity):
|
|||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Get the latest state of the sensor."""
|
"""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._coinbase_data.update()
|
||||||
self._attr_native_value = round(
|
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,
|
self._precision,
|
||||||
)
|
)
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pydaikin"],
|
"loggers": ["pydaikin"],
|
||||||
"requirements": ["pydaikin==2.13.1"],
|
"requirements": ["pydaikin==2.13.2"],
|
||||||
"zeroconf": ["_dkapi._tcp.local."]
|
"zeroconf": ["_dkapi._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -195,7 +195,7 @@ class ConfiguredDoorBird:
|
|||||||
title: str | None = data.get("title")
|
title: str | None = data.get("title")
|
||||||
if not title or not title.startswith("Home Assistant"):
|
if not title or not title.startswith("Home Assistant"):
|
||||||
continue
|
continue
|
||||||
event = title.split("(")[1].strip(")")
|
event = title.partition("(")[2].strip(")")
|
||||||
if input_type := favorite_input_type.get(identifier):
|
if input_type := favorite_input_type.get(identifier):
|
||||||
events.append(DoorbirdEvent(event, input_type))
|
events.append(DoorbirdEvent(event, input_type))
|
||||||
elif input_type := default_event_types.get(event):
|
elif input_type := default_event_types.get(event):
|
||||||
|
@ -431,41 +431,42 @@ def rename_old_gas_to_mbus(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Rename old gas sensor to mbus variant."""
|
"""Rename old gas sensor to mbus variant."""
|
||||||
dev_reg = dr.async_get(hass)
|
dev_reg = dr.async_get(hass)
|
||||||
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
|
for dev_id in (mbus_device_id, entry.entry_id):
|
||||||
if device_entry_v1 is not None:
|
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, dev_id)})
|
||||||
device_id = device_entry_v1.id
|
if device_entry_v1 is not None:
|
||||||
|
device_id = device_entry_v1.id
|
||||||
|
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||||
|
|
||||||
for entity in entries:
|
for entity in entries:
|
||||||
if entity.unique_id.endswith(
|
if entity.unique_id.endswith(
|
||||||
"belgium_5min_gas_meter_reading"
|
"belgium_5min_gas_meter_reading"
|
||||||
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
|
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
|
||||||
try:
|
try:
|
||||||
ent_reg.async_update_entity(
|
ent_reg.async_update_entity(
|
||||||
entity.entity_id,
|
entity.entity_id,
|
||||||
new_unique_id=mbus_device_id,
|
new_unique_id=mbus_device_id,
|
||||||
device_id=mbus_device_id,
|
device_id=mbus_device_id,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Skip migration of %s because it already exists",
|
"Skip migration of %s because it already exists",
|
||||||
entity.entity_id,
|
entity.entity_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Migrated entity %s from unique id %s to %s",
|
"Migrated entity %s from unique id %s to %s",
|
||||||
entity.entity_id,
|
entity.entity_id,
|
||||||
entity.unique_id,
|
entity.unique_id,
|
||||||
mbus_device_id,
|
mbus_device_id,
|
||||||
)
|
)
|
||||||
# Cleanup old device
|
# Cleanup old device
|
||||||
dev_entities = er.async_entries_for_device(
|
dev_entities = er.async_entries_for_device(
|
||||||
ent_reg, device_id, include_disabled_entities=True
|
ent_reg, device_id, include_disabled_entities=True
|
||||||
)
|
)
|
||||||
if not dev_entities:
|
if not dev_entities:
|
||||||
dev_reg.async_remove_device(device_id)
|
dev_reg.async_remove_device(device_id)
|
||||||
|
|
||||||
|
|
||||||
def is_supported_description(
|
def is_supported_description(
|
||||||
|
@ -653,8 +653,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
|||||||
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
|
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
|
||||||
entity_reg, config_entry.entry_id
|
entity_reg, config_entry.entry_id
|
||||||
)
|
)
|
||||||
|
|
||||||
orphan_macs: set[str] = set()
|
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
entry_mac = entity.unique_id.split("_")[0]
|
entry_mac = entity.unique_id.split("_")[0]
|
||||||
if (
|
if (
|
||||||
@ -662,17 +660,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
|||||||
or "_internet_access" in entity.unique_id
|
or "_internet_access" in entity.unique_id
|
||||||
) and entry_mac not in device_hosts:
|
) and entry_mac not in device_hosts:
|
||||||
_LOGGER.info("Removing orphan entity entry %s", entity.entity_id)
|
_LOGGER.info("Removing orphan entity entry %s", entity.entity_id)
|
||||||
orphan_macs.add(entry_mac)
|
|
||||||
entity_reg.async_remove(entity.entity_id)
|
entity_reg.async_remove(entity.entity_id)
|
||||||
|
|
||||||
device_reg = dr.async_get(self.hass)
|
device_reg = dr.async_get(self.hass)
|
||||||
orphan_connections = {
|
valid_connections = {
|
||||||
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in orphan_macs
|
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in device_hosts
|
||||||
}
|
}
|
||||||
for device in dr.async_entries_for_config_entry(
|
for device in dr.async_entries_for_config_entry(
|
||||||
device_reg, config_entry.entry_id
|
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)
|
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||||
device_reg.async_update_device(
|
device_reg.async_update_device(
|
||||||
device.id, remove_config_entry_id=config_entry.entry_id
|
device.id, remove_config_entry_id=config_entry.entry_id
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20240806.1"]
|
"requirements": ["home-assistant-frontend==20240809.0"]
|
||||||
}
|
}
|
||||||
|
@ -22,8 +22,9 @@ from homeassistant.components.notify import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_SERVICE,
|
CONF_ACTION,
|
||||||
CONF_ENTITIES,
|
CONF_ENTITIES,
|
||||||
|
CONF_SERVICE,
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
@ -36,11 +37,37 @@ from .entity import GroupEntity
|
|||||||
|
|
||||||
CONF_SERVICES = "services"
|
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(
|
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_SERVICES): vol.All(
|
vol.Required(CONF_SERVICES): vol.All(
|
||||||
cv.ensure_list,
|
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(
|
tasks.append(
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self.hass.services.async_call(
|
self.hass.services.async_call(
|
||||||
DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True
|
DOMAIN, entity[CONF_ACTION], sending_payload, blocking=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["homewizard_energy"],
|
"loggers": ["homewizard_energy"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["python-homewizard-energy==v6.1.1"],
|
"requirements": ["python-homewizard-energy==v6.2.0"],
|
||||||
"zeroconf": ["_hwenergy._tcp.local."]
|
"zeroconf": ["_hwenergy._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["jvcprojector"],
|
"loggers": ["jvcprojector"],
|
||||||
"requirements": ["pyjvcprojector==1.0.11"]
|
"requirements": ["pyjvcprojector==1.0.12"]
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"dependencies": ["application_credentials"],
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/monzo",
|
"documentation": "https://www.home-assistant.io/integrations/monzo",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["monzopy==1.3.0"]
|
"requirements": ["monzopy==1.3.2"]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyopenweathermap import OWMClient
|
from pyopenweathermap import create_owm_client
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -33,6 +33,7 @@ class OpenweathermapData:
|
|||||||
"""Runtime data definition."""
|
"""Runtime data definition."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
mode: str
|
||||||
coordinator: WeatherUpdateCoordinator
|
coordinator: WeatherUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ async def async_setup_entry(
|
|||||||
else:
|
else:
|
||||||
async_delete_issue(hass, entry.entry_id)
|
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(
|
weather_coordinator = WeatherUpdateCoordinator(
|
||||||
owm_client, latitude, longitude, hass
|
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.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)
|
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_FREE_DAILY = "freedaily"
|
||||||
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
|
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
|
||||||
FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
|
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_MODE_V30 = "v3.0"
|
||||||
OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
|
OWM_MODE_V25 = "v2.5"
|
||||||
DEFAULT_OWM_MODE = OWM_MODE_V30
|
OWM_MODES = [
|
||||||
|
OWM_MODE_FREE_CURRENT,
|
||||||
|
OWM_MODE_FREE_FORECAST,
|
||||||
|
OWM_MODE_V30,
|
||||||
|
OWM_MODE_V25,
|
||||||
|
]
|
||||||
|
DEFAULT_OWM_MODE = OWM_MODE_FREE_CURRENT
|
||||||
|
|
||||||
LANGUAGES = [
|
LANGUAGES = [
|
||||||
"af",
|
"af",
|
||||||
|
@ -86,8 +86,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
"""Format the weather response correctly."""
|
"""Format the weather response correctly."""
|
||||||
_LOGGER.debug("OWM weather response: %s", weather_report)
|
_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 {
|
return {
|
||||||
ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current),
|
ATTR_API_CURRENT: current_weather,
|
||||||
ATTR_API_HOURLY_FORECAST: [
|
ATTR_API_HOURLY_FORECAST: [
|
||||||
self._get_hourly_forecast_weather_data(item)
|
self._get_hourly_forecast_weather_data(item)
|
||||||
for item in weather_report.hourly_forecast
|
for item in weather_report.hourly_forecast
|
||||||
@ -122,6 +128,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
|
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(
|
return Forecast(
|
||||||
datetime=forecast.date_time.isoformat(),
|
datetime=forecast.date_time.isoformat(),
|
||||||
condition=self._get_condition(forecast.condition.id),
|
condition=self._get_condition(forecast.condition.id),
|
||||||
@ -134,12 +142,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
wind_speed=forecast.wind_speed,
|
wind_speed=forecast.wind_speed,
|
||||||
native_wind_gust_speed=forecast.wind_gust,
|
native_wind_gust_speed=forecast.wind_gust,
|
||||||
wind_bearing=forecast.wind_bearing,
|
wind_bearing=forecast.wind_bearing,
|
||||||
uv_index=float(forecast.uv_index),
|
uv_index=uv_index,
|
||||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||||
precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
|
precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
|
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(
|
return Forecast(
|
||||||
datetime=forecast.date_time.isoformat(),
|
datetime=forecast.date_time.isoformat(),
|
||||||
condition=self._get_condition(forecast.condition.id),
|
condition=self._get_condition(forecast.condition.id),
|
||||||
@ -153,7 +163,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
wind_speed=forecast.wind_speed,
|
wind_speed=forecast.wind_speed,
|
||||||
native_wind_gust_speed=forecast.wind_gust,
|
native_wind_gust_speed=forecast.wind_gust,
|
||||||
wind_bearing=forecast.wind_bearing,
|
wind_bearing=forecast.wind_bearing,
|
||||||
uv_index=float(forecast.uv_index),
|
uv_index=uv_index,
|
||||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||||
precipitation=round(forecast.rain + forecast.snow, 2),
|
precipitation=round(forecast.rain + forecast.snow, 2),
|
||||||
)
|
)
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
|
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyopenweathermap"],
|
"loggers": ["pyopenweathermap"],
|
||||||
"requirements": ["pyopenweathermap==0.0.9"]
|
"requirements": ["pyopenweathermap==0.1.1"]
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ from homeassistant.const import (
|
|||||||
UnitOfVolumetricFlux,
|
UnitOfVolumetricFlux,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
@ -47,6 +48,7 @@ from .const import (
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MANUFACTURER,
|
MANUFACTURER,
|
||||||
|
OWM_MODE_FREE_FORECAST,
|
||||||
)
|
)
|
||||||
from .coordinator import WeatherUpdateCoordinator
|
from .coordinator import WeatherUpdateCoordinator
|
||||||
|
|
||||||
@ -161,16 +163,23 @@ async def async_setup_entry(
|
|||||||
name = domain_data.name
|
name = domain_data.name
|
||||||
weather_coordinator = domain_data.coordinator
|
weather_coordinator = domain_data.coordinator
|
||||||
|
|
||||||
entities: list[AbstractOpenWeatherMapSensor] = [
|
if domain_data.mode == OWM_MODE_FREE_FORECAST:
|
||||||
OpenWeatherMapSensor(
|
entity_registry = er.async_get(hass)
|
||||||
name,
|
entries = er.async_entries_for_config_entry(
|
||||||
f"{config_entry.unique_id}-{description.key}",
|
entity_registry, config_entry.entry_id
|
||||||
description,
|
)
|
||||||
weather_coordinator,
|
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):
|
class AbstractOpenWeatherMapSensor(SensorEntity):
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyopenweathermap import OWMClient, RequestError
|
from pyopenweathermap import RequestError, create_owm_client
|
||||||
|
|
||||||
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
|
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ async def validate_api_key(api_key, mode):
|
|||||||
api_key_valid = None
|
api_key_valid = None
|
||||||
errors, description_placeholders = {}, {}
|
errors, description_placeholders = {}, {}
|
||||||
try:
|
try:
|
||||||
owm_client = OWMClient(api_key, mode)
|
owm_client = create_owm_client(api_key, mode)
|
||||||
api_key_valid = await owm_client.validate_key()
|
api_key_valid = await owm_client.validate_key()
|
||||||
except RequestError as error:
|
except RequestError as error:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
|
@ -8,6 +8,7 @@ from homeassistant.components.weather import (
|
|||||||
WeatherEntityFeature,
|
WeatherEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
UnitOfLength,
|
||||||
UnitOfPrecipitationDepth,
|
UnitOfPrecipitationDepth,
|
||||||
UnitOfPressure,
|
UnitOfPressure,
|
||||||
UnitOfSpeed,
|
UnitOfSpeed,
|
||||||
@ -29,6 +30,7 @@ from .const import (
|
|||||||
ATTR_API_HUMIDITY,
|
ATTR_API_HUMIDITY,
|
||||||
ATTR_API_PRESSURE,
|
ATTR_API_PRESSURE,
|
||||||
ATTR_API_TEMPERATURE,
|
ATTR_API_TEMPERATURE,
|
||||||
|
ATTR_API_VISIBILITY_DISTANCE,
|
||||||
ATTR_API_WIND_BEARING,
|
ATTR_API_WIND_BEARING,
|
||||||
ATTR_API_WIND_GUST,
|
ATTR_API_WIND_GUST,
|
||||||
ATTR_API_WIND_SPEED,
|
ATTR_API_WIND_SPEED,
|
||||||
@ -36,6 +38,9 @@ from .const import (
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MANUFACTURER,
|
MANUFACTURER,
|
||||||
|
OWM_MODE_FREE_FORECAST,
|
||||||
|
OWM_MODE_V25,
|
||||||
|
OWM_MODE_V30,
|
||||||
)
|
)
|
||||||
from .coordinator import WeatherUpdateCoordinator
|
from .coordinator import WeatherUpdateCoordinator
|
||||||
|
|
||||||
@ -48,10 +53,11 @@ async def async_setup_entry(
|
|||||||
"""Set up OpenWeatherMap weather entity based on a config entry."""
|
"""Set up OpenWeatherMap weather entity based on a config entry."""
|
||||||
domain_data = config_entry.runtime_data
|
domain_data = config_entry.runtime_data
|
||||||
name = domain_data.name
|
name = domain_data.name
|
||||||
|
mode = domain_data.mode
|
||||||
weather_coordinator = domain_data.coordinator
|
weather_coordinator = domain_data.coordinator
|
||||||
|
|
||||||
unique_id = f"{config_entry.unique_id}"
|
unique_id = f"{config_entry.unique_id}"
|
||||||
owm_weather = OpenWeatherMapWeather(name, unique_id, weather_coordinator)
|
owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator)
|
||||||
|
|
||||||
async_add_entities([owm_weather], False)
|
async_add_entities([owm_weather], False)
|
||||||
|
|
||||||
@ -66,11 +72,13 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
|||||||
_attr_native_pressure_unit = UnitOfPressure.HPA
|
_attr_native_pressure_unit = UnitOfPressure.HPA
|
||||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
|
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
|
||||||
|
_attr_native_visibility_unit = UnitOfLength.METERS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
unique_id: str,
|
unique_id: str,
|
||||||
|
mode: str,
|
||||||
weather_coordinator: WeatherUpdateCoordinator,
|
weather_coordinator: WeatherUpdateCoordinator,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
@ -83,59 +91,71 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
|||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
name=DEFAULT_NAME,
|
name=DEFAULT_NAME,
|
||||||
)
|
)
|
||||||
self._attr_supported_features = (
|
|
||||||
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
|
if mode in (OWM_MODE_V30, OWM_MODE_V25):
|
||||||
)
|
self._attr_supported_features = (
|
||||||
|
WeatherEntityFeature.FORECAST_DAILY
|
||||||
|
| WeatherEntityFeature.FORECAST_HOURLY
|
||||||
|
)
|
||||||
|
elif mode == OWM_MODE_FREE_FORECAST:
|
||||||
|
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def condition(self) -> str | None:
|
def condition(self) -> str | None:
|
||||||
"""Return the current condition."""
|
"""Return the current condition."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CONDITION)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cloud_coverage(self) -> float | None:
|
def cloud_coverage(self) -> float | None:
|
||||||
"""Return the Cloud coverage in %."""
|
"""Return the Cloud coverage in %."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CLOUDS)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_apparent_temperature(self) -> float | None:
|
def native_apparent_temperature(self) -> float | None:
|
||||||
"""Return the apparent temperature."""
|
"""Return the apparent temperature."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE]
|
return self.coordinator.data[ATTR_API_CURRENT].get(
|
||||||
|
ATTR_API_FEELS_LIKE_TEMPERATURE
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_temperature(self) -> float | None:
|
def native_temperature(self) -> float | None:
|
||||||
"""Return the temperature."""
|
"""Return the temperature."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_TEMPERATURE)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_pressure(self) -> float | None:
|
def native_pressure(self) -> float | None:
|
||||||
"""Return the pressure."""
|
"""Return the pressure."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_PRESSURE)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def humidity(self) -> float | None:
|
def humidity(self) -> float | None:
|
||||||
"""Return the humidity."""
|
"""Return the humidity."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_HUMIDITY)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_dew_point(self) -> float | None:
|
def native_dew_point(self) -> float | None:
|
||||||
"""Return the dew point."""
|
"""Return the dew point."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_DEW_POINT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_wind_gust_speed(self) -> float | None:
|
def native_wind_gust_speed(self) -> float | None:
|
||||||
"""Return the wind gust speed."""
|
"""Return the wind gust speed."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_GUST)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_wind_speed(self) -> float | None:
|
def native_wind_speed(self) -> float | None:
|
||||||
"""Return the wind speed."""
|
"""Return the wind speed."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_SPEED)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wind_bearing(self) -> float | str | None:
|
def wind_bearing(self) -> float | str | None:
|
||||||
"""Return the wind bearing."""
|
"""Return the wind bearing."""
|
||||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING]
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visibility(self) -> float | str | None:
|
||||||
|
"""Return visibility."""
|
||||||
|
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||||
|
@ -632,7 +632,7 @@ def _update_states_table_with_foreign_key_options(
|
|||||||
|
|
||||||
def _drop_foreign_key_constraints(
|
def _drop_foreign_key_constraints(
|
||||||
session_maker: Callable[[], Session], engine: Engine, table: str, column: str
|
session_maker: Callable[[], Session], engine: Engine, table: str, column: str
|
||||||
) -> list[tuple[str, str, ReflectedForeignKeyConstraint]]:
|
) -> tuple[bool, list[tuple[str, str, ReflectedForeignKeyConstraint]]]:
|
||||||
"""Drop foreign key constraints for a table on specific columns."""
|
"""Drop foreign key constraints for a table on specific columns."""
|
||||||
inspector = sqlalchemy.inspect(engine)
|
inspector = sqlalchemy.inspect(engine)
|
||||||
dropped_constraints = [
|
dropped_constraints = [
|
||||||
@ -649,6 +649,7 @@ def _drop_foreign_key_constraints(
|
|||||||
if foreign_key["name"] and foreign_key["constrained_columns"] == [column]
|
if foreign_key["name"] and foreign_key["constrained_columns"] == [column]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
fk_remove_ok = True
|
||||||
for drop in drops:
|
for drop in drops:
|
||||||
with session_scope(session=session_maker()) as session:
|
with session_scope(session=session_maker()) as session:
|
||||||
try:
|
try:
|
||||||
@ -660,8 +661,9 @@ def _drop_foreign_key_constraints(
|
|||||||
TABLE_STATES,
|
TABLE_STATES,
|
||||||
column,
|
column,
|
||||||
)
|
)
|
||||||
|
fk_remove_ok = False
|
||||||
|
|
||||||
return dropped_constraints
|
return fk_remove_ok, dropped_constraints
|
||||||
|
|
||||||
|
|
||||||
def _restore_foreign_key_constraints(
|
def _restore_foreign_key_constraints(
|
||||||
@ -1481,7 +1483,7 @@ class _SchemaVersion44Migrator(_SchemaVersionMigrator, target_version=44):
|
|||||||
for column in columns
|
for column in columns
|
||||||
for dropped_constraint in _drop_foreign_key_constraints(
|
for dropped_constraint in _drop_foreign_key_constraints(
|
||||||
self.session_maker, self.engine, table, column
|
self.session_maker, self.engine, table, column
|
||||||
)
|
)[1]
|
||||||
]
|
]
|
||||||
_LOGGER.debug("Dropped foreign key constraints: %s", dropped_constraints)
|
_LOGGER.debug("Dropped foreign key constraints: %s", dropped_constraints)
|
||||||
|
|
||||||
@ -1956,14 +1958,15 @@ def cleanup_legacy_states_event_ids(instance: Recorder) -> bool:
|
|||||||
if instance.dialect_name == SupportedDialect.SQLITE:
|
if instance.dialect_name == SupportedDialect.SQLITE:
|
||||||
# SQLite does not support dropping foreign key constraints
|
# SQLite does not support dropping foreign key constraints
|
||||||
# so we have to rebuild the table
|
# so we have to rebuild the table
|
||||||
rebuild_sqlite_table(session_maker, instance.engine, States)
|
fk_remove_ok = rebuild_sqlite_table(session_maker, instance.engine, States)
|
||||||
else:
|
else:
|
||||||
_drop_foreign_key_constraints(
|
fk_remove_ok, _ = _drop_foreign_key_constraints(
|
||||||
session_maker, instance.engine, TABLE_STATES, "event_id"
|
session_maker, instance.engine, TABLE_STATES, "event_id"
|
||||||
)
|
)
|
||||||
_drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
|
if fk_remove_ok:
|
||||||
instance.use_legacy_events_index = False
|
_drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
|
||||||
_mark_migration_done(session, EventIDPostMigration)
|
instance.use_legacy_events_index = False
|
||||||
|
_mark_migration_done(session, EventIDPostMigration)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -2419,6 +2422,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
|
|||||||
|
|
||||||
migration_id = "event_id_post_migration"
|
migration_id = "event_id_post_migration"
|
||||||
task = MigrationTask
|
task = MigrationTask
|
||||||
|
migration_version = 2
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_data(instance: Recorder) -> bool:
|
def migrate_data(instance: Recorder) -> bool:
|
||||||
@ -2469,7 +2473,7 @@ def _mark_migration_done(
|
|||||||
|
|
||||||
def rebuild_sqlite_table(
|
def rebuild_sqlite_table(
|
||||||
session_maker: Callable[[], Session], engine: Engine, table: type[Base]
|
session_maker: Callable[[], Session], engine: Engine, table: type[Base]
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Rebuild an SQLite table.
|
"""Rebuild an SQLite table.
|
||||||
|
|
||||||
This must only be called after all migrations are complete
|
This must only be called after all migrations are complete
|
||||||
@ -2524,8 +2528,10 @@ def rebuild_sqlite_table(
|
|||||||
# Swallow the exception since we do not want to ever raise
|
# Swallow the exception since we do not want to ever raise
|
||||||
# an integrity error as it would cause the database
|
# an integrity error as it would cause the database
|
||||||
# to be discarded and recreated from scratch
|
# to be discarded and recreated from scratch
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("Rebuilding SQLite table %s finished", orig_name)
|
_LOGGER.warning("Rebuilding SQLite table %s finished", orig_name)
|
||||||
|
return True
|
||||||
finally:
|
finally:
|
||||||
with session_scope(session=session_maker()) as session:
|
with session_scope(session=session_maker()) as session:
|
||||||
# Step 12 - Re-enable foreign keys
|
# Step 12 - Re-enable foreign keys
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiorussound"],
|
"loggers": ["aiorussound"],
|
||||||
"requirements": ["aiorussound==2.2.0"]
|
"requirements": ["aiorussound==2.2.2"]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/tessie",
|
"documentation": "https://www.home-assistant.io/integrations/tessie",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["tessie"],
|
"loggers": ["tessie", "tesla-fleet-api"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.7.3"]
|
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.7.3"]
|
||||||
}
|
}
|
||||||
|
@ -55,12 +55,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
|
|||||||
@property
|
@property
|
||||||
def limit(self) -> int:
|
def limit(self) -> int:
|
||||||
"""Return limit."""
|
"""Return limit."""
|
||||||
return self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT)
|
return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def order(self) -> str:
|
def order(self) -> str:
|
||||||
"""Return order."""
|
"""Return order."""
|
||||||
return self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER)
|
return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER)
|
||||||
|
|
||||||
async def _async_update_data(self) -> SessionStats:
|
async def _async_update_data(self) -> SessionStats:
|
||||||
"""Update transmission data."""
|
"""Update transmission data."""
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"dependencies": ["auth", "application_credentials"],
|
"dependencies": ["auth", "application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/yolink",
|
"documentation": "https://www.home-assistant.io/integrations/yolink",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"requirements": ["yolink-api==0.4.6"]
|
"requirements": ["yolink-api==0.4.7"]
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from zha.application.const import BAUD_RATES, RadioType
|
from zha.application.const import BAUD_RATES, RadioType
|
||||||
@ -12,8 +13,13 @@ from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH
|
|||||||
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
|
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP, Platform
|
from homeassistant.const import (
|
||||||
from homeassistant.core import Event, HomeAssistant
|
CONF_TYPE,
|
||||||
|
EVENT_CORE_CONFIG_UPDATE,
|
||||||
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
@ -204,6 +210,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def update_config(event: Event) -> None:
|
||||||
|
"""Handle Core config update."""
|
||||||
|
zha_gateway.config.local_timezone = ZoneInfo(hass.config.time_zone)
|
||||||
|
|
||||||
|
config_entry.async_on_unload(
|
||||||
|
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_config)
|
||||||
|
)
|
||||||
|
|
||||||
await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities()
|
await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities()
|
||||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES)
|
async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES)
|
||||||
|
@ -15,6 +15,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, ParamSpec, TypeVar, cast
|
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, ParamSpec, TypeVar, cast
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from zha.application.const import (
|
from zha.application.const import (
|
||||||
@ -1273,6 +1274,7 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
|
|||||||
quirks_configuration=quirks_config,
|
quirks_configuration=quirks_config,
|
||||||
device_overrides=overrides_config,
|
device_overrides=overrides_config,
|
||||||
),
|
),
|
||||||
|
local_timezone=ZoneInfo(hass.config.time_zone),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
"zha",
|
"zha",
|
||||||
"universal_silabs_flasher"
|
"universal_silabs_flasher"
|
||||||
],
|
],
|
||||||
"requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.28"],
|
"requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.30"],
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
"vid": "10C4",
|
"vid": "10C4",
|
||||||
|
@ -817,9 +817,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non
|
|||||||
|
|
||||||
This method is a coroutine.
|
This method is a coroutine.
|
||||||
"""
|
"""
|
||||||
# CORE_CONFIG_SCHEMA is not async safe since it uses vol.IsDir
|
config = CORE_CONFIG_SCHEMA(config)
|
||||||
# so we need to run it in an executor job.
|
|
||||||
config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config)
|
|
||||||
|
|
||||||
# Only load auth during startup.
|
# Only load auth during startup.
|
||||||
if not hasattr(hass, "auth"):
|
if not hasattr(hass, "auth"):
|
||||||
@ -1535,15 +1533,9 @@ async def async_process_component_config(
|
|||||||
return IntegrationConfigInfo(None, config_exceptions)
|
return IntegrationConfigInfo(None, config_exceptions)
|
||||||
|
|
||||||
# No custom config validator, proceed with schema validation
|
# No custom config validator, proceed with schema validation
|
||||||
if config_schema := getattr(component, "CONFIG_SCHEMA", None):
|
if hasattr(component, "CONFIG_SCHEMA"):
|
||||||
try:
|
try:
|
||||||
if domain in config:
|
return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), [])
|
||||||
# cv.isdir, cv.isfile, cv.isdevice are not async
|
|
||||||
# friendly so we need to run this in executor
|
|
||||||
schema = await hass.async_add_executor_job(config_schema, config)
|
|
||||||
else:
|
|
||||||
schema = config_schema(config)
|
|
||||||
return IntegrationConfigInfo(schema, [])
|
|
||||||
except vol.Invalid as exc:
|
except vol.Invalid as exc:
|
||||||
exc_info = ConfigExceptionInfo(
|
exc_info = ConfigExceptionInfo(
|
||||||
exc,
|
exc,
|
||||||
|
@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
|||||||
APPLICATION_NAME: Final = "HomeAssistant"
|
APPLICATION_NAME: Final = "HomeAssistant"
|
||||||
MAJOR_VERSION: Final = 2024
|
MAJOR_VERSION: Final = 2024
|
||||||
MINOR_VERSION: Final = 8
|
MINOR_VERSION: Final = 8
|
||||||
PATCH_VERSION: Final = "0"
|
PATCH_VERSION: Final = "1"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
||||||
|
@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2
|
|||||||
aiodiscover==2.1.0
|
aiodiscover==2.1.0
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
aiohttp-fast-zlib==0.1.1
|
aiohttp-fast-zlib==0.1.1
|
||||||
aiohttp==3.10.1
|
aiohttp==3.10.2
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiozoneinfo==0.2.1
|
aiozoneinfo==0.2.1
|
||||||
astral==2.2
|
astral==2.2
|
||||||
@ -31,7 +31,7 @@ habluetooth==3.1.3
|
|||||||
hass-nabucasa==0.81.1
|
hass-nabucasa==0.81.1
|
||||||
hassil==1.7.4
|
hassil==1.7.4
|
||||||
home-assistant-bluetooth==1.12.2
|
home-assistant-bluetooth==1.12.2
|
||||||
home-assistant-frontend==20240806.1
|
home-assistant-frontend==20240809.0
|
||||||
home-assistant-intents==2024.8.7
|
home-assistant-intents==2024.8.7
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "homeassistant"
|
name = "homeassistant"
|
||||||
version = "2024.8.0"
|
version = "2024.8.1"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "Open-source home automation platform running on Python 3."
|
description = "Open-source home automation platform running on Python 3."
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
@ -24,7 +24,7 @@ classifiers = [
|
|||||||
requires-python = ">=3.12.0"
|
requires-python = ">=3.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiodns==3.2.0",
|
"aiodns==3.2.0",
|
||||||
"aiohttp==3.10.1",
|
"aiohttp==3.10.2",
|
||||||
"aiohttp_cors==0.7.0",
|
"aiohttp_cors==0.7.0",
|
||||||
"aiohttp-fast-zlib==0.1.1",
|
"aiohttp-fast-zlib==0.1.1",
|
||||||
"aiozoneinfo==0.2.1",
|
"aiozoneinfo==0.2.1",
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# Home Assistant Core
|
# Home Assistant Core
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
aiohttp==3.10.1
|
aiohttp==3.10.2
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiohttp-fast-zlib==0.1.1
|
aiohttp-fast-zlib==0.1.1
|
||||||
aiozoneinfo==0.2.1
|
aiozoneinfo==0.2.1
|
||||||
|
@ -350,7 +350,7 @@ aioridwell==2024.01.0
|
|||||||
aioruckus==0.34
|
aioruckus==0.34
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==2.2.0
|
aiorussound==2.2.2
|
||||||
|
|
||||||
# homeassistant.components.ruuvi_gateway
|
# homeassistant.components.ruuvi_gateway
|
||||||
aioruuvigateway==0.1.0
|
aioruuvigateway==0.1.0
|
||||||
@ -410,7 +410,7 @@ aiowithings==3.0.2
|
|||||||
aioymaps==1.2.5
|
aioymaps==1.2.5
|
||||||
|
|
||||||
# homeassistant.components.airgradient
|
# homeassistant.components.airgradient
|
||||||
airgradient==0.7.1
|
airgradient==0.8.0
|
||||||
|
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
@ -660,6 +660,9 @@ clearpasspy==1.0.2
|
|||||||
# homeassistant.components.sinch
|
# homeassistant.components.sinch
|
||||||
clx-sdk-xms==1.0.0
|
clx-sdk-xms==1.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.coinbase
|
||||||
|
coinbase-advanced-py==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.coinbase
|
# homeassistant.components.coinbase
|
||||||
coinbase==2.1.0
|
coinbase==2.1.0
|
||||||
|
|
||||||
@ -732,7 +735,7 @@ devolo-home-control-api==0.18.3
|
|||||||
devolo-plc-api==1.4.1
|
devolo-plc-api==1.4.1
|
||||||
|
|
||||||
# homeassistant.components.chacon_dio
|
# homeassistant.components.chacon_dio
|
||||||
dio-chacon-wifi-api==1.1.0
|
dio-chacon-wifi-api==1.2.0
|
||||||
|
|
||||||
# homeassistant.components.directv
|
# homeassistant.components.directv
|
||||||
directv==0.4.0
|
directv==0.4.0
|
||||||
@ -1093,7 +1096,7 @@ hole==0.8.0
|
|||||||
holidays==0.53
|
holidays==0.53
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20240806.1
|
home-assistant-frontend==20240809.0
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2024.8.7
|
home-assistant-intents==2024.8.7
|
||||||
@ -1351,7 +1354,7 @@ moat-ble==0.1.1
|
|||||||
moehlenhoff-alpha2==1.3.1
|
moehlenhoff-alpha2==1.3.1
|
||||||
|
|
||||||
# homeassistant.components.monzo
|
# homeassistant.components.monzo
|
||||||
monzopy==1.3.0
|
monzopy==1.3.2
|
||||||
|
|
||||||
# homeassistant.components.mopeka
|
# homeassistant.components.mopeka
|
||||||
mopeka-iot-ble==0.8.0
|
mopeka-iot-ble==0.8.0
|
||||||
@ -1786,7 +1789,7 @@ pycsspeechtts==1.0.8
|
|||||||
# pycups==1.9.73
|
# pycups==1.9.73
|
||||||
|
|
||||||
# homeassistant.components.daikin
|
# homeassistant.components.daikin
|
||||||
pydaikin==2.13.1
|
pydaikin==2.13.2
|
||||||
|
|
||||||
# homeassistant.components.danfoss_air
|
# homeassistant.components.danfoss_air
|
||||||
pydanfossair==0.1.0
|
pydanfossair==0.1.0
|
||||||
@ -1942,7 +1945,7 @@ pyisy==3.1.14
|
|||||||
pyitachip2ir==0.0.7
|
pyitachip2ir==0.0.7
|
||||||
|
|
||||||
# homeassistant.components.jvc_projector
|
# homeassistant.components.jvc_projector
|
||||||
pyjvcprojector==1.0.11
|
pyjvcprojector==1.0.12
|
||||||
|
|
||||||
# homeassistant.components.kaleidescape
|
# homeassistant.components.kaleidescape
|
||||||
pykaleidescape==1.0.1
|
pykaleidescape==1.0.1
|
||||||
@ -2068,7 +2071,7 @@ pyombi==0.1.10
|
|||||||
pyopenuv==2023.02.0
|
pyopenuv==2023.02.0
|
||||||
|
|
||||||
# homeassistant.components.openweathermap
|
# homeassistant.components.openweathermap
|
||||||
pyopenweathermap==0.0.9
|
pyopenweathermap==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.opnsense
|
# homeassistant.components.opnsense
|
||||||
pyopnsense==0.4.0
|
pyopnsense==0.4.0
|
||||||
@ -2280,7 +2283,7 @@ python-gitlab==1.6.0
|
|||||||
python-homeassistant-analytics==0.7.0
|
python-homeassistant-analytics==0.7.0
|
||||||
|
|
||||||
# homeassistant.components.homewizard
|
# homeassistant.components.homewizard
|
||||||
python-homewizard-energy==v6.1.1
|
python-homewizard-energy==v6.2.0
|
||||||
|
|
||||||
# homeassistant.components.hp_ilo
|
# homeassistant.components.hp_ilo
|
||||||
python-hpilo==4.4.3
|
python-hpilo==4.4.3
|
||||||
@ -2962,7 +2965,7 @@ yeelight==0.7.14
|
|||||||
yeelightsunflower==0.0.10
|
yeelightsunflower==0.0.10
|
||||||
|
|
||||||
# homeassistant.components.yolink
|
# homeassistant.components.yolink
|
||||||
yolink-api==0.4.6
|
yolink-api==0.4.7
|
||||||
|
|
||||||
# homeassistant.components.youless
|
# homeassistant.components.youless
|
||||||
youless-api==2.1.2
|
youless-api==2.1.2
|
||||||
@ -2986,7 +2989,7 @@ zeroconf==0.132.2
|
|||||||
zeversolar==0.3.1
|
zeversolar==0.3.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zha==0.0.28
|
zha==0.0.30
|
||||||
|
|
||||||
# homeassistant.components.zhong_hong
|
# homeassistant.components.zhong_hong
|
||||||
zhong-hong-hvac==1.0.12
|
zhong-hong-hvac==1.0.12
|
||||||
|
@ -332,7 +332,7 @@ aioridwell==2024.01.0
|
|||||||
aioruckus==0.34
|
aioruckus==0.34
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==2.2.0
|
aiorussound==2.2.2
|
||||||
|
|
||||||
# homeassistant.components.ruuvi_gateway
|
# homeassistant.components.ruuvi_gateway
|
||||||
aioruuvigateway==0.1.0
|
aioruuvigateway==0.1.0
|
||||||
@ -392,7 +392,7 @@ aiowithings==3.0.2
|
|||||||
aioymaps==1.2.5
|
aioymaps==1.2.5
|
||||||
|
|
||||||
# homeassistant.components.airgradient
|
# homeassistant.components.airgradient
|
||||||
airgradient==0.7.1
|
airgradient==0.8.0
|
||||||
|
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
@ -562,6 +562,9 @@ cached_ipaddress==0.3.0
|
|||||||
# homeassistant.components.caldav
|
# homeassistant.components.caldav
|
||||||
caldav==1.3.9
|
caldav==1.3.9
|
||||||
|
|
||||||
|
# homeassistant.components.coinbase
|
||||||
|
coinbase-advanced-py==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.coinbase
|
# homeassistant.components.coinbase
|
||||||
coinbase==2.1.0
|
coinbase==2.1.0
|
||||||
|
|
||||||
@ -625,7 +628,7 @@ devolo-home-control-api==0.18.3
|
|||||||
devolo-plc-api==1.4.1
|
devolo-plc-api==1.4.1
|
||||||
|
|
||||||
# homeassistant.components.chacon_dio
|
# homeassistant.components.chacon_dio
|
||||||
dio-chacon-wifi-api==1.1.0
|
dio-chacon-wifi-api==1.2.0
|
||||||
|
|
||||||
# homeassistant.components.directv
|
# homeassistant.components.directv
|
||||||
directv==0.4.0
|
directv==0.4.0
|
||||||
@ -916,7 +919,7 @@ hole==0.8.0
|
|||||||
holidays==0.53
|
holidays==0.53
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20240806.1
|
home-assistant-frontend==20240809.0
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2024.8.7
|
home-assistant-intents==2024.8.7
|
||||||
@ -1117,7 +1120,7 @@ moat-ble==0.1.1
|
|||||||
moehlenhoff-alpha2==1.3.1
|
moehlenhoff-alpha2==1.3.1
|
||||||
|
|
||||||
# homeassistant.components.monzo
|
# homeassistant.components.monzo
|
||||||
monzopy==1.3.0
|
monzopy==1.3.2
|
||||||
|
|
||||||
# homeassistant.components.mopeka
|
# homeassistant.components.mopeka
|
||||||
mopeka-iot-ble==0.8.0
|
mopeka-iot-ble==0.8.0
|
||||||
@ -1433,7 +1436,7 @@ pycoolmasternet-async==0.1.5
|
|||||||
pycsspeechtts==1.0.8
|
pycsspeechtts==1.0.8
|
||||||
|
|
||||||
# homeassistant.components.daikin
|
# homeassistant.components.daikin
|
||||||
pydaikin==2.13.1
|
pydaikin==2.13.2
|
||||||
|
|
||||||
# homeassistant.components.deconz
|
# homeassistant.components.deconz
|
||||||
pydeconz==116
|
pydeconz==116
|
||||||
@ -1547,7 +1550,7 @@ pyiss==1.0.1
|
|||||||
pyisy==3.1.14
|
pyisy==3.1.14
|
||||||
|
|
||||||
# homeassistant.components.jvc_projector
|
# homeassistant.components.jvc_projector
|
||||||
pyjvcprojector==1.0.11
|
pyjvcprojector==1.0.12
|
||||||
|
|
||||||
# homeassistant.components.kaleidescape
|
# homeassistant.components.kaleidescape
|
||||||
pykaleidescape==1.0.1
|
pykaleidescape==1.0.1
|
||||||
@ -1652,7 +1655,7 @@ pyoctoprintapi==0.1.12
|
|||||||
pyopenuv==2023.02.0
|
pyopenuv==2023.02.0
|
||||||
|
|
||||||
# homeassistant.components.openweathermap
|
# homeassistant.components.openweathermap
|
||||||
pyopenweathermap==0.0.9
|
pyopenweathermap==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.opnsense
|
# homeassistant.components.opnsense
|
||||||
pyopnsense==0.4.0
|
pyopnsense==0.4.0
|
||||||
@ -1804,7 +1807,7 @@ python-fullykiosk==0.0.14
|
|||||||
python-homeassistant-analytics==0.7.0
|
python-homeassistant-analytics==0.7.0
|
||||||
|
|
||||||
# homeassistant.components.homewizard
|
# homeassistant.components.homewizard
|
||||||
python-homewizard-energy==v6.1.1
|
python-homewizard-energy==v6.2.0
|
||||||
|
|
||||||
# homeassistant.components.izone
|
# homeassistant.components.izone
|
||||||
python-izone==1.2.9
|
python-izone==1.2.9
|
||||||
@ -2339,7 +2342,7 @@ yalexs==6.4.3
|
|||||||
yeelight==0.7.14
|
yeelight==0.7.14
|
||||||
|
|
||||||
# homeassistant.components.yolink
|
# homeassistant.components.yolink
|
||||||
yolink-api==0.4.6
|
yolink-api==0.4.7
|
||||||
|
|
||||||
# homeassistant.components.youless
|
# homeassistant.components.youless
|
||||||
youless-api==2.1.2
|
youless-api==2.1.2
|
||||||
@ -2360,7 +2363,7 @@ zeroconf==0.132.2
|
|||||||
zeversolar==0.3.1
|
zeversolar==0.3.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zha==0.0.28
|
zha==0.0.30
|
||||||
|
|
||||||
# homeassistant.components.zwave_js
|
# homeassistant.components.zwave_js
|
||||||
zwave-js-server-python==0.57.0
|
zwave-js-server-python==0.57.0
|
||||||
|
@ -5,13 +5,14 @@ from homeassistant.components.coinbase.const import (
|
|||||||
CONF_EXCHANGE_RATES,
|
CONF_EXCHANGE_RATES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
|
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
GOOD_CURRENCY_2,
|
GOOD_CURRENCY_2,
|
||||||
GOOD_EXCHANGE_RATE,
|
GOOD_EXCHANGE_RATE,
|
||||||
GOOD_EXCHANGE_RATE_2,
|
GOOD_EXCHANGE_RATE_2,
|
||||||
MOCK_ACCOUNTS_RESPONSE,
|
MOCK_ACCOUNTS_RESPONSE,
|
||||||
|
MOCK_ACCOUNTS_RESPONSE_V3,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
@ -54,6 +55,33 @@ def mocked_get_accounts(_, **kwargs):
|
|||||||
return MockGetAccounts(**kwargs)
|
return MockGetAccounts(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MockGetAccountsV3:
|
||||||
|
"""Mock accounts with pagination."""
|
||||||
|
|
||||||
|
def __init__(self, cursor=""):
|
||||||
|
"""Init mocked object, forced to return two at a time."""
|
||||||
|
ids = [account["uuid"] for account in MOCK_ACCOUNTS_RESPONSE_V3]
|
||||||
|
start = ids.index(cursor) if cursor else 0
|
||||||
|
|
||||||
|
has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3)
|
||||||
|
end = target_end if has_next else -1
|
||||||
|
next_cursor = ids[end] if has_next else ids[-1]
|
||||||
|
self.accounts = {
|
||||||
|
"accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end],
|
||||||
|
"has_next": has_next,
|
||||||
|
"cursor": next_cursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
"""Handle subscript request."""
|
||||||
|
return self.accounts[item]
|
||||||
|
|
||||||
|
|
||||||
|
def mocked_get_accounts_v3(_, **kwargs):
|
||||||
|
"""Return simplified accounts using mock."""
|
||||||
|
return MockGetAccountsV3(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
def mock_get_current_user():
|
def mock_get_current_user():
|
||||||
"""Return a simplified mock user."""
|
"""Return a simplified mock user."""
|
||||||
return {
|
return {
|
||||||
@ -74,6 +102,19 @@ def mock_get_exchange_rates():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mock_get_portfolios():
|
||||||
|
"""Return a mocked list of Coinbase portfolios."""
|
||||||
|
return {
|
||||||
|
"portfolios": [
|
||||||
|
{
|
||||||
|
"name": "Default",
|
||||||
|
"uuid": "123456",
|
||||||
|
"type": "DEFAULT",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def init_mock_coinbase(hass, currencies=None, rates=None):
|
async def init_mock_coinbase(hass, currencies=None, rates=None):
|
||||||
"""Init Coinbase integration for testing."""
|
"""Init Coinbase integration for testing."""
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
@ -93,3 +134,28 @@ async def init_mock_coinbase(hass, currencies=None, rates=None):
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
return config_entry
|
return config_entry
|
||||||
|
|
||||||
|
|
||||||
|
async def init_mock_coinbase_v3(hass, currencies=None, rates=None):
|
||||||
|
"""Init Coinbase integration for testing."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
entry_id="080272b77a4f80c41b94d7cdc86fd826",
|
||||||
|
unique_id=None,
|
||||||
|
title="Test User v3",
|
||||||
|
data={
|
||||||
|
CONF_API_KEY: "organizations/123456",
|
||||||
|
CONF_API_TOKEN: "AbCDeF",
|
||||||
|
CONF_API_VERSION: "v3",
|
||||||
|
},
|
||||||
|
options={
|
||||||
|
CONF_CURRENCIES: currencies or [],
|
||||||
|
CONF_EXCHANGE_RATES: rates or [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return config_entry
|
||||||
|
@ -31,3 +31,31 @@ MOCK_ACCOUNTS_RESPONSE = [
|
|||||||
"type": "fiat",
|
"type": "fiat",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
MOCK_ACCOUNTS_RESPONSE_V3 = [
|
||||||
|
{
|
||||||
|
"uuid": "123456789",
|
||||||
|
"name": "BTC Wallet",
|
||||||
|
"currency": GOOD_CURRENCY,
|
||||||
|
"available_balance": {"value": "0.00001", "currency": GOOD_CURRENCY},
|
||||||
|
"type": "ACCOUNT_TYPE_CRYPTO",
|
||||||
|
"hold": {"value": "0", "currency": GOOD_CURRENCY},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "abcdefg",
|
||||||
|
"name": "BTC Vault",
|
||||||
|
"currency": GOOD_CURRENCY,
|
||||||
|
"available_balance": {"value": "100.00", "currency": GOOD_CURRENCY},
|
||||||
|
"type": "ACCOUNT_TYPE_VAULT",
|
||||||
|
"hold": {"value": "0", "currency": GOOD_CURRENCY},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "987654321",
|
||||||
|
"name": "USD Wallet",
|
||||||
|
"currency": GOOD_CURRENCY_2,
|
||||||
|
"available_balance": {"value": "9.90", "currency": GOOD_CURRENCY_2},
|
||||||
|
"type": "ACCOUNT_TYPE_FIAT",
|
||||||
|
"ready": True,
|
||||||
|
"hold": {"value": "0", "currency": GOOD_CURRENCY_2},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
@ -3,40 +3,25 @@
|
|||||||
dict({
|
dict({
|
||||||
'accounts': list([
|
'accounts': list([
|
||||||
dict({
|
dict({
|
||||||
'balance': dict({
|
'amount': '**REDACTED**',
|
||||||
'amount': '**REDACTED**',
|
'currency': 'BTC',
|
||||||
'currency': 'BTC',
|
|
||||||
}),
|
|
||||||
'currency': dict({
|
|
||||||
'code': 'BTC',
|
|
||||||
}),
|
|
||||||
'id': '**REDACTED**',
|
'id': '**REDACTED**',
|
||||||
|
'is_vault': False,
|
||||||
'name': 'BTC Wallet',
|
'name': 'BTC Wallet',
|
||||||
'type': 'wallet',
|
|
||||||
}),
|
}),
|
||||||
dict({
|
dict({
|
||||||
'balance': dict({
|
'amount': '**REDACTED**',
|
||||||
'amount': '**REDACTED**',
|
'currency': 'BTC',
|
||||||
'currency': 'BTC',
|
|
||||||
}),
|
|
||||||
'currency': dict({
|
|
||||||
'code': 'BTC',
|
|
||||||
}),
|
|
||||||
'id': '**REDACTED**',
|
'id': '**REDACTED**',
|
||||||
|
'is_vault': True,
|
||||||
'name': 'BTC Vault',
|
'name': 'BTC Vault',
|
||||||
'type': 'vault',
|
|
||||||
}),
|
}),
|
||||||
dict({
|
dict({
|
||||||
'balance': dict({
|
'amount': '**REDACTED**',
|
||||||
'amount': '**REDACTED**',
|
'currency': 'USD',
|
||||||
'currency': 'USD',
|
|
||||||
}),
|
|
||||||
'currency': dict({
|
|
||||||
'code': 'USD',
|
|
||||||
}),
|
|
||||||
'id': '**REDACTED**',
|
'id': '**REDACTED**',
|
||||||
|
'is_vault': False,
|
||||||
'name': 'USD Wallet',
|
'name': 'USD Wallet',
|
||||||
'type': 'fiat',
|
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
'entry': dict({
|
'entry': dict({
|
||||||
|
@ -14,15 +14,18 @@ from homeassistant.components.coinbase.const import (
|
|||||||
CONF_EXCHANGE_RATES,
|
CONF_EXCHANGE_RATES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
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
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
init_mock_coinbase,
|
init_mock_coinbase,
|
||||||
|
init_mock_coinbase_v3,
|
||||||
mock_get_current_user,
|
mock_get_current_user,
|
||||||
mock_get_exchange_rates,
|
mock_get_exchange_rates,
|
||||||
|
mock_get_portfolios,
|
||||||
mocked_get_accounts,
|
mocked_get_accounts,
|
||||||
|
mocked_get_accounts_v3,
|
||||||
)
|
)
|
||||||
from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE
|
from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE
|
||||||
|
|
||||||
@ -53,16 +56,17 @@ async def test_form(hass: HomeAssistant) -> None:
|
|||||||
):
|
):
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"},
|
||||||
CONF_API_KEY: "123456",
|
|
||||||
CONF_API_TOKEN: "AbCDeF",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result2["title"] == "Test User"
|
assert result2["title"] == "Test User"
|
||||||
assert result2["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}
|
assert result2["data"] == {
|
||||||
|
CONF_API_KEY: "123456",
|
||||||
|
CONF_API_TOKEN: "AbCDeF",
|
||||||
|
CONF_API_VERSION: "v2",
|
||||||
|
}
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
@ -314,3 +318,77 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
assert result2["type"] is FlowResultType.FORM
|
assert result2["type"] is FlowResultType.FORM
|
||||||
assert result2["errors"] == {"base": "unknown"}
|
assert result2["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_v3(hass: HomeAssistant) -> None:
|
||||||
|
"""Test we get the form."""
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
|
||||||
|
patch(
|
||||||
|
"coinbase.rest.RESTClient.get_portfolios",
|
||||||
|
return_value=mock_get_portfolios(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"coinbase.rest.RESTBase.get",
|
||||||
|
return_value={"data": mock_get_exchange_rates()},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.coinbase.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry,
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF"},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2["title"] == "Default"
|
||||||
|
assert result2["data"] == {
|
||||||
|
CONF_API_KEY: "organizations/123456",
|
||||||
|
CONF_API_TOKEN: "AbCDeF",
|
||||||
|
CONF_API_VERSION: "v3",
|
||||||
|
}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_option_form_v3(hass: HomeAssistant) -> None:
|
||||||
|
"""Test we handle a good wallet currency option."""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
|
||||||
|
patch(
|
||||||
|
"coinbase.rest.RESTClient.get_portfolios",
|
||||||
|
return_value=mock_get_portfolios(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"coinbase.rest.RESTBase.get",
|
||||||
|
return_value={"data": mock_get_exchange_rates()},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.coinbase.update_listener"
|
||||||
|
) as mock_update_listener,
|
||||||
|
):
|
||||||
|
config_entry = await init_mock_coinbase_v3(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result2 = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_CURRENCIES: [GOOD_CURRENCY],
|
||||||
|
CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE],
|
||||||
|
CONF_EXCHANGE_PRECISION: 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_update_listener.mock_calls) == 1
|
||||||
|
@ -7,6 +7,10 @@
|
|||||||
"1": {
|
"1": {
|
||||||
"title": "Home Assistant (mydoorbird_motion)",
|
"title": "Home Assistant (mydoorbird_motion)",
|
||||||
"value": "http://127.0.0.1:8123/api/doorbird/mydoorbird_motion?token=01J2F4B97Y7P1SARXEJ6W07EKD"
|
"value": "http://127.0.0.1:8123/api/doorbird/mydoorbird_motion?token=01J2F4B97Y7P1SARXEJ6W07EKD"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "externally added event",
|
||||||
|
"value": "http://127.0.0.1/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,4 +49,4 @@ async def test_reset_favorites_button(
|
|||||||
DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: reset_entity_id}, blocking=True
|
DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: reset_entity_id}, blocking=True
|
||||||
)
|
)
|
||||||
assert hass.states.get(reset_entity_id).state != STATE_UNKNOWN
|
assert hass.states.get(reset_entity_id).state != STATE_UNKNOWN
|
||||||
assert doorbird_entry.api.delete_favorite.call_count == 2
|
assert doorbird_entry.api.delete_favorite.call_count == 3
|
||||||
|
@ -219,6 +219,101 @@ async def test_migrate_hourly_gas_to_mbus(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_gas_with_devid_to_mbus(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
|
||||||
|
) -> None:
|
||||||
|
"""Test migration of unique_id."""
|
||||||
|
(connection_factory, transport, protocol) = dsmr_connection_fixture
|
||||||
|
|
||||||
|
mock_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id="/dev/ttyUSB0",
|
||||||
|
data={
|
||||||
|
"port": "/dev/ttyUSB0",
|
||||||
|
"dsmr_version": "5B",
|
||||||
|
"serial_id": "1234",
|
||||||
|
"serial_id_gas": "37464C4F32313139303333373331",
|
||||||
|
},
|
||||||
|
options={
|
||||||
|
"time_between_update": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading"
|
||||||
|
|
||||||
|
device = device_registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, "37464C4F32313139303333373331")},
|
||||||
|
name="Gas Meter",
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity: er.RegistryEntry = entity_registry.async_get_or_create(
|
||||||
|
suggested_object_id="gas_meter_reading",
|
||||||
|
disabled_by=None,
|
||||||
|
domain=SENSOR_DOMAIN,
|
||||||
|
platform=DOMAIN,
|
||||||
|
device_id=device.id,
|
||||||
|
unique_id=old_unique_id,
|
||||||
|
config_entry=mock_entry,
|
||||||
|
)
|
||||||
|
assert entity.unique_id == old_unique_id
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
telegram = Telegram()
|
||||||
|
telegram.add(
|
||||||
|
MBUS_DEVICE_TYPE,
|
||||||
|
CosemObject((0, 1), [{"value": "003", "unit": ""}]),
|
||||||
|
"MBUS_DEVICE_TYPE",
|
||||||
|
)
|
||||||
|
telegram.add(
|
||||||
|
MBUS_EQUIPMENT_IDENTIFIER,
|
||||||
|
CosemObject(
|
||||||
|
(0, 1),
|
||||||
|
[{"value": "37464C4F32313139303333373331", "unit": ""}],
|
||||||
|
),
|
||||||
|
"MBUS_EQUIPMENT_IDENTIFIER",
|
||||||
|
)
|
||||||
|
telegram.add(
|
||||||
|
MBUS_METER_READING,
|
||||||
|
MBusObject(
|
||||||
|
(0, 1),
|
||||||
|
[
|
||||||
|
{"value": datetime.datetime.fromtimestamp(1551642213)},
|
||||||
|
{"value": Decimal(745.695), "unit": "m3"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"MBUS_METER_READING",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
telegram_callback = connection_factory.call_args_list[0][0][2]
|
||||||
|
|
||||||
|
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
|
||||||
|
telegram_callback(telegram)
|
||||||
|
|
||||||
|
# after receiving telegram entities need to have the chance to be created
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
entity_registry.async_get_entity_id(
|
||||||
|
SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331"
|
||||||
|
)
|
||||||
|
== "sensor.gas_meter_reading"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_migrate_gas_to_mbus_exists(
|
async def test_migrate_gas_to_mbus_exists(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
@ -122,7 +122,7 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No
|
|||||||
"services": [
|
"services": [
|
||||||
{"service": "test_service1"},
|
{"service": "test_service1"},
|
||||||
{
|
{
|
||||||
"service": "test_service2",
|
"action": "test_service2",
|
||||||
"data": {
|
"data": {
|
||||||
"target": "unnamed device",
|
"target": "unnamed device",
|
||||||
"data": {"test": "message", "default": "default"},
|
"data": {"test": "message", "default": "default"},
|
||||||
@ -202,6 +202,41 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_configuration(
|
||||||
|
hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test failing to set up group with an invalid configuration."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"group",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
group_setup = [
|
||||||
|
{
|
||||||
|
"platform": "group",
|
||||||
|
"name": "My invalid notification group",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"service": "test_service1",
|
||||||
|
"action": "test_service2",
|
||||||
|
"data": {
|
||||||
|
"target": "unnamed device",
|
||||||
|
"data": {"test": "message", "default": "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
await help_setup_notify(hass, tmp_path, {"service1": 1, "service2": 2}, group_setup)
|
||||||
|
assert not hass.services.has_service("notify", "my_invalid_notification_group")
|
||||||
|
assert (
|
||||||
|
"Invalid config for 'notify' from integration 'group':"
|
||||||
|
" Cannot specify both 'service' and 'action'." in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None:
|
async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||||
"""Verify we can reload the notify service."""
|
"""Verify we can reload the notify service."""
|
||||||
assert await async_setup_component(
|
assert await async_setup_component(
|
||||||
@ -219,7 +254,7 @@ async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None:
|
|||||||
{
|
{
|
||||||
"name": "group_notify",
|
"name": "group_notify",
|
||||||
"platform": "group",
|
"platform": "group",
|
||||||
"services": [{"service": "test_service1"}],
|
"services": [{"action": "test_service1"}],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -45,7 +45,7 @@ CONFIG = {
|
|||||||
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
|
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
|
||||||
|
|
||||||
|
|
||||||
def _create_mocked_owm_client(is_valid: bool):
|
def _create_mocked_owm_factory(is_valid: bool):
|
||||||
current_weather = CurrentWeather(
|
current_weather = CurrentWeather(
|
||||||
date_time=datetime.fromtimestamp(1714063536, tz=UTC),
|
date_time=datetime.fromtimestamp(1714063536, tz=UTC),
|
||||||
temperature=6.84,
|
temperature=6.84,
|
||||||
@ -118,18 +118,18 @@ def _create_mocked_owm_client(is_valid: bool):
|
|||||||
def mock_owm_client():
|
def mock_owm_client():
|
||||||
"""Mock config_flow OWMClient."""
|
"""Mock config_flow OWMClient."""
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.openweathermap.OWMClient",
|
"homeassistant.components.openweathermap.create_owm_client",
|
||||||
) as owm_client_mock:
|
) as mock:
|
||||||
yield owm_client_mock
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="config_flow_owm_client_mock")
|
@pytest.fixture(name="config_flow_owm_client_mock")
|
||||||
def mock_config_flow_owm_client():
|
def mock_config_flow_owm_client():
|
||||||
"""Mock config_flow OWMClient."""
|
"""Mock config_flow OWMClient."""
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.openweathermap.utils.OWMClient",
|
"homeassistant.components.openweathermap.utils.create_owm_client",
|
||||||
) as config_flow_owm_client_mock:
|
) as mock:
|
||||||
yield config_flow_owm_client_mock
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
async def test_successful_config_flow(
|
async def test_successful_config_flow(
|
||||||
@ -138,7 +138,7 @@ async def test_successful_config_flow(
|
|||||||
config_flow_owm_client_mock,
|
config_flow_owm_client_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the form is served with valid input."""
|
"""Test that the form is served with valid input."""
|
||||||
mock = _create_mocked_owm_client(True)
|
mock = _create_mocked_owm_factory(True)
|
||||||
owm_client_mock.return_value = mock
|
owm_client_mock.return_value = mock
|
||||||
config_flow_owm_client_mock.return_value = mock
|
config_flow_owm_client_mock.return_value = mock
|
||||||
|
|
||||||
@ -177,7 +177,7 @@ async def test_abort_config_flow(
|
|||||||
config_flow_owm_client_mock,
|
config_flow_owm_client_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the form is served with same data."""
|
"""Test that the form is served with same data."""
|
||||||
mock = _create_mocked_owm_client(True)
|
mock = _create_mocked_owm_factory(True)
|
||||||
owm_client_mock.return_value = mock
|
owm_client_mock.return_value = mock
|
||||||
config_flow_owm_client_mock.return_value = mock
|
config_flow_owm_client_mock.return_value = mock
|
||||||
|
|
||||||
@ -200,7 +200,7 @@ async def test_config_flow_options_change(
|
|||||||
config_flow_owm_client_mock,
|
config_flow_owm_client_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the options form."""
|
"""Test that the options form."""
|
||||||
mock = _create_mocked_owm_client(True)
|
mock = _create_mocked_owm_factory(True)
|
||||||
owm_client_mock.return_value = mock
|
owm_client_mock.return_value = mock
|
||||||
config_flow_owm_client_mock.return_value = mock
|
config_flow_owm_client_mock.return_value = mock
|
||||||
|
|
||||||
@ -261,7 +261,7 @@ async def test_form_invalid_api_key(
|
|||||||
config_flow_owm_client_mock,
|
config_flow_owm_client_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the form is served with no input."""
|
"""Test that the form is served with no input."""
|
||||||
config_flow_owm_client_mock.return_value = _create_mocked_owm_client(False)
|
config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(False)
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||||
)
|
)
|
||||||
@ -269,7 +269,7 @@ async def test_form_invalid_api_key(
|
|||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
assert result["errors"] == {"base": "invalid_api_key"}
|
assert result["errors"] == {"base": "invalid_api_key"}
|
||||||
|
|
||||||
config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True)
|
config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True)
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input=CONFIG
|
result["flow_id"], user_input=CONFIG
|
||||||
)
|
)
|
||||||
@ -282,7 +282,7 @@ async def test_form_api_call_error(
|
|||||||
config_flow_owm_client_mock,
|
config_flow_owm_client_mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setting up with api call error."""
|
"""Test setting up with api call error."""
|
||||||
config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True)
|
config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True)
|
||||||
config_flow_owm_client_mock.side_effect = RequestError("oops")
|
config_flow_owm_client_mock.side_effect = RequestError("oops")
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||||
|
@ -748,7 +748,7 @@ def test_rebuild_sqlite_states_table(recorder_db_url: str) -> None:
|
|||||||
session.add(States(state="on"))
|
session.add(States(state="on"))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
migration.rebuild_sqlite_table(session_maker, engine, States)
|
assert migration.rebuild_sqlite_table(session_maker, engine, States) is True
|
||||||
|
|
||||||
with session_scope(session=session_maker()) as session:
|
with session_scope(session=session_maker()) as session:
|
||||||
assert session.query(States).count() == 1
|
assert session.query(States).count() == 1
|
||||||
@ -776,13 +776,13 @@ def test_rebuild_sqlite_states_table_missing_fails(
|
|||||||
session.connection().execute(text("DROP TABLE states"))
|
session.connection().execute(text("DROP TABLE states"))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
migration.rebuild_sqlite_table(session_maker, engine, States)
|
assert migration.rebuild_sqlite_table(session_maker, engine, States) is False
|
||||||
assert "Error recreating SQLite table states" in caplog.text
|
assert "Error recreating SQLite table states" in caplog.text
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
# Now rebuild the events table to make sure the database did not
|
# Now rebuild the events table to make sure the database did not
|
||||||
# get corrupted
|
# get corrupted
|
||||||
migration.rebuild_sqlite_table(session_maker, engine, Events)
|
assert migration.rebuild_sqlite_table(session_maker, engine, Events) is True
|
||||||
|
|
||||||
with session_scope(session=session_maker()) as session:
|
with session_scope(session=session_maker()) as session:
|
||||||
assert session.query(Events).count() == 1
|
assert session.query(Events).count() == 1
|
||||||
@ -812,7 +812,7 @@ def test_rebuild_sqlite_states_table_extra_columns(
|
|||||||
text("ALTER TABLE states ADD COLUMN extra_column TEXT")
|
text("ALTER TABLE states ADD COLUMN extra_column TEXT")
|
||||||
)
|
)
|
||||||
|
|
||||||
migration.rebuild_sqlite_table(session_maker, engine, States)
|
assert migration.rebuild_sqlite_table(session_maker, engine, States) is True
|
||||||
assert "Error recreating SQLite table states" not in caplog.text
|
assert "Error recreating SQLite table states" not in caplog.text
|
||||||
|
|
||||||
with session_scope(session=session_maker()) as session:
|
with session_scope(session=session_maker()) as session:
|
||||||
@ -905,7 +905,7 @@ def test_drop_restore_foreign_key_constraints(recorder_db_url: str) -> None:
|
|||||||
for table, column in constraints_to_recreate
|
for table, column in constraints_to_recreate
|
||||||
for dropped_constraint in migration._drop_foreign_key_constraints(
|
for dropped_constraint in migration._drop_foreign_key_constraints(
|
||||||
session_maker, engine, table, column
|
session_maker, engine, table, column
|
||||||
)
|
)[1]
|
||||||
]
|
]
|
||||||
assert dropped_constraints_1 == expected_dropped_constraints[db_engine]
|
assert dropped_constraints_1 == expected_dropped_constraints[db_engine]
|
||||||
|
|
||||||
@ -917,7 +917,7 @@ def test_drop_restore_foreign_key_constraints(recorder_db_url: str) -> None:
|
|||||||
for table, column in constraints_to_recreate
|
for table, column in constraints_to_recreate
|
||||||
for dropped_constraint in migration._drop_foreign_key_constraints(
|
for dropped_constraint in migration._drop_foreign_key_constraints(
|
||||||
session_maker, engine, table, column
|
session_maker, engine, table, column
|
||||||
)
|
)[1]
|
||||||
]
|
]
|
||||||
assert dropped_constraints_2 == []
|
assert dropped_constraints_2 == []
|
||||||
|
|
||||||
@ -936,7 +936,7 @@ def test_drop_restore_foreign_key_constraints(recorder_db_url: str) -> None:
|
|||||||
for table, column in constraints_to_recreate
|
for table, column in constraints_to_recreate
|
||||||
for dropped_constraint in migration._drop_foreign_key_constraints(
|
for dropped_constraint in migration._drop_foreign_key_constraints(
|
||||||
session_maker, engine, table, column
|
session_maker, engine, table, column
|
||||||
)
|
)[1]
|
||||||
]
|
]
|
||||||
assert dropped_constraints_3 == expected_dropped_constraints[db_engine]
|
assert dropped_constraints_3 == expected_dropped_constraints[db_engine]
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, inspect
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from homeassistant.components import recorder
|
from homeassistant.components import recorder
|
||||||
@ -444,3 +445,348 @@ async def test_migrate_can_resume_ix_states_event_id_removed(
|
|||||||
assert await instance.async_add_executor_job(_get_event_id_foreign_keys) is None
|
assert await instance.async_add_executor_job(_get_event_id_foreign_keys) is None
|
||||||
|
|
||||||
await hass.async_stop()
|
await hass.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("skip_by_db_engine")
|
||||||
|
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
|
||||||
|
@pytest.mark.parametrize("enable_migrate_event_ids", [True])
|
||||||
|
@pytest.mark.parametrize("persistent_database", [True])
|
||||||
|
@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage
|
||||||
|
async def test_out_of_disk_space_while_rebuild_states_table(
|
||||||
|
async_test_recorder: RecorderInstanceGenerator,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
recorder_db_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test that we can recover from out of disk space while rebuilding the states table.
|
||||||
|
|
||||||
|
This case tests the migration still happens if
|
||||||
|
ix_states_event_id is removed from the states table.
|
||||||
|
"""
|
||||||
|
importlib.import_module(SCHEMA_MODULE)
|
||||||
|
old_db_schema = sys.modules[SCHEMA_MODULE]
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
one_second_past = now - timedelta(seconds=1)
|
||||||
|
mock_state = State(
|
||||||
|
"sensor.test",
|
||||||
|
"old",
|
||||||
|
{"last_reset": now.isoformat()},
|
||||||
|
last_changed=one_second_past,
|
||||||
|
last_updated=now,
|
||||||
|
)
|
||||||
|
state_changed_event = Event(
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.test",
|
||||||
|
"old_state": None,
|
||||||
|
"new_state": mock_state,
|
||||||
|
},
|
||||||
|
EventOrigin.local,
|
||||||
|
time_fired_timestamp=now.timestamp(),
|
||||||
|
)
|
||||||
|
custom_event = Event(
|
||||||
|
"custom_event",
|
||||||
|
{"entity_id": "sensor.custom"},
|
||||||
|
EventOrigin.local,
|
||||||
|
time_fired_timestamp=now.timestamp(),
|
||||||
|
)
|
||||||
|
number_of_migrations = 5
|
||||||
|
|
||||||
|
def _get_event_id_foreign_keys():
|
||||||
|
assert instance.engine is not None
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
fk # type: ignore[misc]
|
||||||
|
for fk in inspect(instance.engine).get_foreign_keys("states")
|
||||||
|
if fk["constrained_columns"] == ["event_id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_states_index_names():
|
||||||
|
with session_scope(hass=hass) as session:
|
||||||
|
return inspect(session.connection()).get_indexes("states")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(recorder, "db_schema", old_db_schema),
|
||||||
|
patch.object(
|
||||||
|
recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION
|
||||||
|
),
|
||||||
|
patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
|
||||||
|
patch.object(core, "EventTypes", old_db_schema.EventTypes),
|
||||||
|
patch.object(core, "EventData", old_db_schema.EventData),
|
||||||
|
patch.object(core, "States", old_db_schema.States),
|
||||||
|
patch.object(core, "Events", old_db_schema.Events),
|
||||||
|
patch(CREATE_ENGINE_TARGET, new=_create_engine_test),
|
||||||
|
patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
def _add_data():
|
||||||
|
with session_scope(hass=hass) as session:
|
||||||
|
session.add(old_db_schema.Events.from_event(custom_event))
|
||||||
|
session.add(old_db_schema.States.from_event(state_changed_event))
|
||||||
|
|
||||||
|
await instance.async_add_executor_job(_add_data)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
|
||||||
|
await instance.async_add_executor_job(
|
||||||
|
migration._drop_index,
|
||||||
|
instance.get_session,
|
||||||
|
"states",
|
||||||
|
"ix_states_event_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(
|
||||||
|
_get_states_index_names
|
||||||
|
)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is True
|
||||||
|
assert (
|
||||||
|
await instance.async_add_executor_job(_get_event_id_foreign_keys)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert "ix_states_entity_id_last_updated_ts" in states_index_names
|
||||||
|
|
||||||
|
# Simulate out of disk space while rebuilding the states table by
|
||||||
|
# - patching CreateTable to raise SQLAlchemyError for SQLite
|
||||||
|
# - patching DropConstraint to raise InternalError for MySQL and PostgreSQL
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.recorder.migration.CreateTable",
|
||||||
|
side_effect=SQLAlchemyError,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.recorder.migration.DropConstraint",
|
||||||
|
side_effect=OperationalError(
|
||||||
|
None, None, OSError("No space left on device")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We need to wait for all the migration tasks to complete
|
||||||
|
# before we can check the database.
|
||||||
|
for _ in range(number_of_migrations):
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(
|
||||||
|
_get_states_index_names
|
||||||
|
)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is True
|
||||||
|
assert "Error recreating SQLite table states" in caplog.text
|
||||||
|
assert await instance.async_add_executor_job(_get_event_id_foreign_keys)
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
|
||||||
|
# Now run it again to verify the table rebuild tries again
|
||||||
|
caplog.clear()
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We need to wait for all the migration tasks to complete
|
||||||
|
# before we can check the database.
|
||||||
|
for _ in range(number_of_migrations):
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(_get_states_index_names)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is False
|
||||||
|
assert "ix_states_entity_id_last_updated_ts" not in states_index_names
|
||||||
|
assert "ix_states_event_id" not in states_index_names
|
||||||
|
assert "Rebuilding SQLite table states finished" in caplog.text
|
||||||
|
assert await instance.async_add_executor_job(_get_event_id_foreign_keys) is None
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("skip_by_db_engine")
|
||||||
|
@pytest.mark.skip_on_db_engine(["sqlite"])
|
||||||
|
@pytest.mark.parametrize("enable_migrate_event_ids", [True])
|
||||||
|
@pytest.mark.parametrize("persistent_database", [True])
|
||||||
|
@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage
|
||||||
|
async def test_out_of_disk_space_while_removing_foreign_key(
|
||||||
|
async_test_recorder: RecorderInstanceGenerator,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
recorder_db_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test that we can recover from out of disk space while removing the foreign key.
|
||||||
|
|
||||||
|
This case tests the migration still happens if
|
||||||
|
ix_states_event_id is removed from the states table.
|
||||||
|
"""
|
||||||
|
importlib.import_module(SCHEMA_MODULE)
|
||||||
|
old_db_schema = sys.modules[SCHEMA_MODULE]
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
one_second_past = now - timedelta(seconds=1)
|
||||||
|
mock_state = State(
|
||||||
|
"sensor.test",
|
||||||
|
"old",
|
||||||
|
{"last_reset": now.isoformat()},
|
||||||
|
last_changed=one_second_past,
|
||||||
|
last_updated=now,
|
||||||
|
)
|
||||||
|
state_changed_event = Event(
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
|
{
|
||||||
|
"entity_id": "sensor.test",
|
||||||
|
"old_state": None,
|
||||||
|
"new_state": mock_state,
|
||||||
|
},
|
||||||
|
EventOrigin.local,
|
||||||
|
time_fired_timestamp=now.timestamp(),
|
||||||
|
)
|
||||||
|
custom_event = Event(
|
||||||
|
"custom_event",
|
||||||
|
{"entity_id": "sensor.custom"},
|
||||||
|
EventOrigin.local,
|
||||||
|
time_fired_timestamp=now.timestamp(),
|
||||||
|
)
|
||||||
|
number_of_migrations = 5
|
||||||
|
|
||||||
|
def _get_event_id_foreign_keys():
|
||||||
|
assert instance.engine is not None
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
fk # type: ignore[misc]
|
||||||
|
for fk in inspect(instance.engine).get_foreign_keys("states")
|
||||||
|
if fk["constrained_columns"] == ["event_id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_states_index_names():
|
||||||
|
with session_scope(hass=hass) as session:
|
||||||
|
return inspect(session.connection()).get_indexes("states")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(recorder, "db_schema", old_db_schema),
|
||||||
|
patch.object(
|
||||||
|
recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION
|
||||||
|
),
|
||||||
|
patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
|
||||||
|
patch.object(core, "EventTypes", old_db_schema.EventTypes),
|
||||||
|
patch.object(core, "EventData", old_db_schema.EventData),
|
||||||
|
patch.object(core, "States", old_db_schema.States),
|
||||||
|
patch.object(core, "Events", old_db_schema.Events),
|
||||||
|
patch(CREATE_ENGINE_TARGET, new=_create_engine_test),
|
||||||
|
patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
def _add_data():
|
||||||
|
with session_scope(hass=hass) as session:
|
||||||
|
session.add(old_db_schema.Events.from_event(custom_event))
|
||||||
|
session.add(old_db_schema.States.from_event(state_changed_event))
|
||||||
|
|
||||||
|
await instance.async_add_executor_job(_add_data)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
|
||||||
|
await instance.async_add_executor_job(
|
||||||
|
migration._drop_index,
|
||||||
|
instance.get_session,
|
||||||
|
"states",
|
||||||
|
"ix_states_event_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(
|
||||||
|
_get_states_index_names
|
||||||
|
)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is True
|
||||||
|
assert (
|
||||||
|
await instance.async_add_executor_job(_get_event_id_foreign_keys)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert "ix_states_entity_id_last_updated_ts" in states_index_names
|
||||||
|
|
||||||
|
# Simulate out of disk space while removing the foreign key from the states table by
|
||||||
|
# - patching DropConstraint to raise InternalError for MySQL and PostgreSQL
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.recorder.migration.DropConstraint",
|
||||||
|
side_effect=OperationalError(
|
||||||
|
None, None, OSError("No space left on device")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We need to wait for all the migration tasks to complete
|
||||||
|
# before we can check the database.
|
||||||
|
for _ in range(number_of_migrations):
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(
|
||||||
|
_get_states_index_names
|
||||||
|
)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is True
|
||||||
|
assert await instance.async_add_executor_job(_get_event_id_foreign_keys)
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
|
||||||
|
# Now run it again to verify the table rebuild tries again
|
||||||
|
caplog.clear()
|
||||||
|
async with (
|
||||||
|
async_test_home_assistant() as hass,
|
||||||
|
async_test_recorder(hass) as instance,
|
||||||
|
):
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We need to wait for all the migration tasks to complete
|
||||||
|
# before we can check the database.
|
||||||
|
for _ in range(number_of_migrations):
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
states_indexes = await instance.async_add_executor_job(_get_states_index_names)
|
||||||
|
states_index_names = {index["name"] for index in states_indexes}
|
||||||
|
assert instance.use_legacy_events_index is False
|
||||||
|
assert "ix_states_entity_id_last_updated_ts" not in states_index_names
|
||||||
|
assert "ix_states_event_id" not in states_index_names
|
||||||
|
assert await instance.async_add_executor_job(_get_event_id_foreign_keys) is None
|
||||||
|
|
||||||
|
await hass.async_stop()
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from zigpy.application import ControllerApplication
|
from zigpy.application import ControllerApplication
|
||||||
@ -16,7 +17,7 @@ from homeassistant.components.zha.const import (
|
|||||||
CONF_USB_PATH,
|
CONF_USB_PATH,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from homeassistant.components.zha.helpers import get_zha_data
|
from homeassistant.components.zha.helpers import get_zha_data, get_zha_gateway
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
MAJOR_VERSION,
|
MAJOR_VERSION,
|
||||||
@ -288,3 +289,23 @@ async def test_shutdown_on_ha_stop(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert len(mock_shutdown.mock_calls) == 1
|
assert len(mock_shutdown.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_timezone_update(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
mock_zigpy_connect: ControllerApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the ZHA gateway timezone is updated when HA timezone changes."""
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
gateway = get_zha_gateway(hass)
|
||||||
|
|
||||||
|
assert hass.config.time_zone == "US/Pacific"
|
||||||
|
assert gateway.config.local_timezone == zoneinfo.ZoneInfo("US/Pacific")
|
||||||
|
|
||||||
|
await hass.config.async_update(time_zone="America/New_York")
|
||||||
|
|
||||||
|
assert hass.config.time_zone == "America/New_York"
|
||||||
|
assert gateway.config.local_timezone == zoneinfo.ZoneInfo("America/New_York")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user