forked from home-assistant/core
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1edea46a4d | |||
| c476e92bdc | |||
| 8dcd9945e8 | |||
| 6953c20a65 | |||
| 4e8186491c | |||
| 6fa93edf27 | |||
| ef13b35c35 | |||
| 0afdd9556f | |||
| e11ead410b | |||
| ef7058f703 | |||
| 938855bea3 | |||
| 4c00c56afd | |||
| 8cc7e7b76f | |||
| df006aeade | |||
| ffac522554 | |||
| 9502dbee56 | |||
| a339fbaa82 | |||
| b02eaed6b0 | |||
| df594748cf | |||
| 744a7a0e82 | |||
| f677b910a6 | |||
| 0da6b28808 | |||
| f111a2c34a | |||
| 59eb323f8d | |||
| 7ae13a4d72 | |||
| 735b843f5e | |||
| 5b1783e859 | |||
| 7b14b6af0e | |||
| cc18ec2de8 | |||
| df59adf5d1 | |||
| 8c98cede60 | |||
| b1a70c86c3 | |||
| 63daed0ed6 | |||
| 2150a668b0 | |||
| b505722f38 | |||
| 036eef2b6b | |||
| f3fb7cd8e8 | |||
| 42f55bf271 | |||
| 6d7dad41d9 | |||
| 9dbce6d904 | |||
| 7f0db3181d |
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
|
||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
|
||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -531,7 +531,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
|
||||
uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 11
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.3"
|
||||
HA_SHORT_VERSION: "2025.4"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -1276,7 +1276,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@v5.3.1
|
||||
uses: codecov/codecov-action@v5.4.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1415,7 +1415,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@v5.3.1
|
||||
uses: codecov/codecov-action@v5.4.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -218,7 +218,15 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
- name: Split requirements all
|
||||
run: |
|
||||
# We split requirements all into multiple files.
|
||||
# This is to prevent the build from running out of memory when
|
||||
# resolving packages on 32-bits systems (like armhf, armv7).
|
||||
|
||||
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
|
||||
|
||||
- name: Build wheels (part 1)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
@@ -230,4 +238,32 @@ jobs:
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
|
||||
- name: Build wheels (part 2)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtab"
|
||||
|
||||
- name: Build wheels (part 3)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtac"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.7
|
||||
rev: v0.9.8
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
|
||||
from homeassistant.exceptions import ServiceNotFound, ServiceValidationError
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
@@ -195,8 +195,7 @@ class AlertEntity(Entity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Async Acknowledge alert."""
|
||||
if not self._can_ack:
|
||||
raise ServiceValidationError("This alert cannot be acknowledged")
|
||||
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
|
||||
self._ack = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Apollo Automation."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "apollo_automation",
|
||||
"name": "Apollo Automation",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "esphome"
|
||||
}
|
||||
@@ -13,7 +13,11 @@ from azure.storage.blob.aio import ContainerClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
@@ -52,7 +56,7 @@ async def async_setup_entry(
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except ClientAuthenticationError as err:
|
||||
raise ConfigEntryError(
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for Azure Storage integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -26,6 +27,26 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
def get_account_url(self, account_name: str) -> str:
|
||||
"""Get the account URL."""
|
||||
return f"https://{account_name}.blob.core.windows.net/"
|
||||
|
||||
async def validate_config(
|
||||
self, container_client: ContainerClient
|
||||
) -> dict[str, str]:
|
||||
"""Validate the configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await container_client.exists()
|
||||
except ResourceNotFoundError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ClientAuthenticationError:
|
||||
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -38,20 +59,13 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]),
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
try:
|
||||
await container_client.exists()
|
||||
except ResourceNotFoundError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ClientAuthenticationError:
|
||||
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
errors = await self.validate_config(container_client)
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
|
||||
@@ -70,3 +84,77 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]),
|
||||
container_name=reauth_entry.data[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data={**reauth_entry.data, **user_input},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure the entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(
|
||||
reconfigure_entry.data[CONF_ACCOUNT_NAME]
|
||||
),
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data={**reconfigure_entry.data, **user_input},
|
||||
)
|
||||
return self.async_show_form(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_CONTAINER_NAME,
|
||||
default=reconfigure_entry.data[CONF_CONTAINER_NAME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have platforms.
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
@@ -121,7 +121,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
|
||||
@@ -19,10 +19,34 @@
|
||||
},
|
||||
"description": "Set up an Azure (Blob) storage account to be used for backups.",
|
||||
"title": "Add Azure storage account"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]"
|
||||
},
|
||||
"description": "Provide a new storage account key.",
|
||||
"title": "Reauthenticate Azure storage account"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]",
|
||||
"storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]",
|
||||
"storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]"
|
||||
},
|
||||
"description": "Change the settings of the Azure storage integration.",
|
||||
"title": "Reconfigure Azure storage account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -14,7 +14,6 @@ from itertools import chain
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
||||
@@ -309,12 +308,6 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
|
||||
_message = "On-the-fly decryption is not supported for this backup."
|
||||
|
||||
|
||||
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
|
||||
"""Raised when multiple exceptions occur."""
|
||||
|
||||
error_code = "multiple_errors"
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Define the format that backup managers can have."""
|
||||
|
||||
@@ -1612,24 +1605,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
# If there's an unhandled exception, we keep it so we can rethrow it in case
|
||||
# the post backup actions also fail.
|
||||
unhandled_exc = sys.exception()
|
||||
try:
|
||||
try:
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
except Exception as err:
|
||||
if not unhandled_exc:
|
||||
raise
|
||||
# If there's an unhandled exception, we wrap both that and the exception
|
||||
# from the post backup actions in an ExceptionGroup so the caller is
|
||||
# aware of both exceptions.
|
||||
raise BackupManagerExceptionGroup(
|
||||
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
|
||||
[unhandled_exc, err],
|
||||
) from None
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
|
||||
def _mkdir_and_generate_backup_contents(
|
||||
self,
|
||||
|
||||
@@ -153,27 +153,6 @@ def _has_min_duration(
|
||||
return validate
|
||||
|
||||
|
||||
def _has_positive_interval(
|
||||
start_key: str, end_key: str, duration_key: str
|
||||
) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||
"""Verify that the time span between start and end is greater than zero."""
|
||||
|
||||
def validate(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
if (duration := obj.get(duration_key)) is not None:
|
||||
if duration <= datetime.timedelta(seconds=0):
|
||||
raise vol.Invalid(f"Expected positive duration ({duration})")
|
||||
return obj
|
||||
|
||||
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
|
||||
if start >= end:
|
||||
raise vol.Invalid(
|
||||
f"Expected end time to be after start time ({start}, {end})"
|
||||
)
|
||||
return obj
|
||||
|
||||
return validate
|
||||
|
||||
|
||||
def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||
"""Verify that all values are of the same type."""
|
||||
|
||||
@@ -302,7 +281,6 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All(
|
||||
),
|
||||
}
|
||||
),
|
||||
_has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
|
||||
)
|
||||
|
||||
|
||||
@@ -892,7 +870,6 @@ async def async_get_events_service(
|
||||
end = start + service_call.data[EVENT_DURATION]
|
||||
else:
|
||||
end = service_call.data[EVENT_END_DATETIME]
|
||||
|
||||
calendar_event_list = await calendar.async_get_events(
|
||||
calendar.hass, dt_util.as_local(start), dt_util.as_local(end)
|
||||
)
|
||||
|
||||
@@ -68,6 +68,7 @@ from .const import ( # noqa: F401
|
||||
FAN_ON,
|
||||
FAN_TOP,
|
||||
HVAC_MODES,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
|
||||
@@ -126,6 +126,7 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN = "climate"
|
||||
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Intents for the climate integration."""
|
||||
"""Intents for the client integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.helpers import config_validation as cv, intent
|
||||
from . import (
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
@@ -19,9 +20,49 @@ from . import (
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"""Set up the climate intents."""
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
intent.async_register(hass, SetTemperatureIntent())
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
class SetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle SetTemperature intents."""
|
||||
|
||||
|
||||
@@ -49,11 +49,7 @@ def async_get_chat_log(
|
||||
raise RuntimeError(
|
||||
"Cannot attach chat log delta listener unless initial caller"
|
||||
)
|
||||
if user_input is not None and (
|
||||
(content := chat_log.content[-1]).role != "user"
|
||||
# MyPy doesn't understand that content is a UserContent here
|
||||
or content.content != user_input.text # type: ignore[union-attr]
|
||||
):
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
yield chat_log
|
||||
|
||||
@@ -24,14 +24,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
EventStateReportedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -39,10 +32,7 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_state_report_event,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
@@ -210,33 +200,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.warning("Could not restore last state: %s", err)
|
||||
|
||||
@callback
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
if self._attr_native_value == Decimal(0):
|
||||
# If the derivative is zero, and the source sensor hasn't
|
||||
# changed state, then we know it will still be zero.
|
||||
return
|
||||
new_state = event.data["new_state"]
|
||||
if new_state is not None:
|
||||
calc_derivative(
|
||||
new_state, new_state.state, event.data["old_last_reported"]
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
new_state = event.data["new_state"]
|
||||
old_state = event.data["old_state"]
|
||||
if new_state is not None and old_state is not None:
|
||||
calc_derivative(new_state, old_state.state, old_state.last_reported)
|
||||
|
||||
def calc_derivative(
|
||||
new_state: State, old_value: str, old_last_reported: datetime
|
||||
) -> None:
|
||||
def calc_derivative(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle the sensor state changes."""
|
||||
if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
|
||||
STATE_UNKNOWN,
|
||||
STATE_UNAVAILABLE,
|
||||
if (
|
||||
(old_state := event.data["old_state"]) is None
|
||||
or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
or (new_state := event.data["new_state"]) is None
|
||||
or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
):
|
||||
return
|
||||
|
||||
@@ -250,15 +220,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (new_state.last_reported - time_end).total_seconds()
|
||||
if (new_state.last_updated - time_end).total_seconds()
|
||||
< self._time_window
|
||||
]
|
||||
|
||||
try:
|
||||
elapsed_time = (
|
||||
new_state.last_reported - old_last_reported
|
||||
new_state.last_updated - old_state.last_updated
|
||||
).total_seconds()
|
||||
delta_value = Decimal(new_state.state) - Decimal(old_value)
|
||||
delta_value = Decimal(new_state.state) - Decimal(old_state.state)
|
||||
new_derivative = (
|
||||
delta_value
|
||||
/ Decimal(elapsed_time)
|
||||
@@ -270,7 +240,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.warning("While calculating derivative: %s", err)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"Invalid state (%s > %s): %s", old_value, new_state.state, err
|
||||
"Invalid state (%s > %s): %s", old_state.state, new_state.state, err
|
||||
)
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate derivative: %s", err)
|
||||
@@ -287,7 +257,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
# add latest derivative to the window list
|
||||
self._state_list.append(
|
||||
(old_last_reported, new_state.last_reported, new_derivative)
|
||||
(old_state.last_updated, new_state.last_updated, new_derivative)
|
||||
)
|
||||
|
||||
def calculate_weight(
|
||||
@@ -307,19 +277,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
else:
|
||||
derivative = Decimal("0.00")
|
||||
for start, end, value in self._state_list:
|
||||
weight = calculate_weight(start, end, new_state.last_reported)
|
||||
weight = calculate_weight(start, end, new_state.last_updated)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
self._attr_native_value = round(derivative, self._round_digits)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, self._sensor_source_id, on_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_report_event(
|
||||
self.hass, self._sensor_source_id, on_state_reported
|
||||
self.hass, self._sensor_source_id, calc_derivative
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from devolo_plc_api.device_api import (
|
||||
WifiGuestAccessGet,
|
||||
)
|
||||
from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
@@ -44,7 +43,7 @@ class DevoloEntity(Entity):
|
||||
self.entry = entry
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=URL.build(scheme="http", host=self.device.ip),
|
||||
configuration_url=f"http://{self.device.ip}",
|
||||
identifiers={(DOMAIN, str(self.device.serial_number))},
|
||||
manufacturer="devolo",
|
||||
model=self.device.product,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.1.28"]
|
||||
"requirements": ["pyeconet==0.1.23"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"]
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
)
|
||||
_element: Area
|
||||
|
||||
@@ -205,7 +204,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
|
||||
ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME,
|
||||
ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
|
||||
ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT,
|
||||
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION,
|
||||
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY,
|
||||
}
|
||||
|
||||
if self._element.alarm_state is None:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.6"]
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env-canada==0.8.0"]
|
||||
"requirements": ["env-canada==0.7.2"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from typing import Any, cast
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -239,13 +238,9 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
@esphome_state_property
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
if (
|
||||
not self._static_info.supports_current_humidity
|
||||
or (val := self._state.current_humidity) is None
|
||||
or not isfinite(val)
|
||||
):
|
||||
if not self._static_info.supports_current_humidity:
|
||||
return None
|
||||
return round(val)
|
||||
return round(self._state.current_humidity)
|
||||
|
||||
@property
|
||||
@esphome_float_state_property
|
||||
|
||||
@@ -13,13 +13,11 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.2.2"
|
||||
STABLE_BLE_VERSION_STR = "2025.2.1"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
}
|
||||
# ESPHome always uses .0 for the changelog URL
|
||||
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
|
||||
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
|
||||
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html"
|
||||
|
||||
DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"
|
||||
|
||||
@@ -13,9 +13,7 @@ from . import CONF_NOISE_PSK
|
||||
from .dashboard import async_get_dashboard
|
||||
from .entry_data import ESPHomeConfigEntry
|
||||
|
||||
CONF_MAC_ADDRESS = "mac_address"
|
||||
|
||||
REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS}
|
||||
REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.2.0",
|
||||
"aioesphomeapi==29.3.1",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.8.0"
|
||||
],
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyfibaro"],
|
||||
"requirements": ["pyfibaro==0.8.0"]
|
||||
"requirements": ["pyfibaro==0.8.2"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250228.0"]
|
||||
"requirements": ["home-assistant-frontend==20250227.0"]
|
||||
}
|
||||
|
||||
@@ -20,4 +20,3 @@ MAX_ERRORS = 2
|
||||
TARGET_TEMPERATURE_STEP = 1
|
||||
|
||||
UPDATE_INTERVAL = 60
|
||||
MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -25,7 +24,6 @@ from .const import (
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
MAX_ERRORS,
|
||||
MAX_EXPECTED_RESPONSE_TIME_INTERVAL,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
@@ -50,6 +48,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self.device.add_handler(Response.DATA, self.device_state_updated)
|
||||
self.device.add_handler(Response.RESULT, self.device_state_updated)
|
||||
|
||||
self._error_count: int = 0
|
||||
@@ -89,9 +88,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# raise update failed if time for more than MAX_ERRORS has passed since last update
|
||||
now = utcnow()
|
||||
elapsed_success = now - self._last_response_time
|
||||
if self.update_interval and elapsed_success >= timedelta(
|
||||
seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL
|
||||
):
|
||||
if self.update_interval and elapsed_success >= self.update_interval:
|
||||
if not self._last_error_time or (
|
||||
(now - self.update_interval) >= self._last_error_time
|
||||
):
|
||||
@@ -99,19 +96,16 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._error_count += 1
|
||||
|
||||
_LOGGER.warning(
|
||||
"Device %s took an unusually long time to respond, %s seconds",
|
||||
"Device %s is unresponsive for %s seconds",
|
||||
self.name,
|
||||
elapsed_success,
|
||||
)
|
||||
else:
|
||||
self._error_count = 0
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unresponsive for too long and now unavailable"
|
||||
)
|
||||
|
||||
self._last_response_time = utcnow()
|
||||
return copy.deepcopy(self.device.raw_properties)
|
||||
return self.device.raw_properties
|
||||
|
||||
async def push_state_update(self):
|
||||
"""Send state updates to the physical device."""
|
||||
|
||||
@@ -26,7 +26,6 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="todayEnergy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="total_output_power",
|
||||
@@ -34,7 +33,6 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="invTodayPpv",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="total_energy_output",
|
||||
|
||||
@@ -11,6 +11,7 @@ from hko import HKO, HKOError
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_FOG,
|
||||
ATTR_CONDITION_HAIL,
|
||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
@@ -144,7 +145,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Return the condition corresponding to the weather info."""
|
||||
info = info.lower()
|
||||
if WEATHER_INFO_RAIN in info:
|
||||
return ATTR_CONDITION_RAINY
|
||||
return ATTR_CONDITION_HAIL
|
||||
if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info:
|
||||
return ATTR_CONDITION_SNOWY_RAINY
|
||||
if WEATHER_INFO_SNOW in info:
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.68", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.67", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
|
||||
|
||||
EVENT_STREAM_RECONNECT_DELAY = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectApplianceData:
|
||||
@@ -98,7 +100,6 @@ class HomeConnectCoordinator(
|
||||
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||
] = {}
|
||||
self.device_registry = dr.async_get(self.hass)
|
||||
self.data = {}
|
||||
|
||||
@cached_property
|
||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||
@@ -156,20 +157,10 @@ class HomeConnectCoordinator(
|
||||
|
||||
async def _event_listener(self) -> None:
|
||||
"""Match event with listener for event type."""
|
||||
retry_time = 10
|
||||
while True:
|
||||
try:
|
||||
async for event_message in self.client.stream_all_events():
|
||||
retry_time = 10
|
||||
event_message_ha_id = event_message.ha_id
|
||||
if (
|
||||
event_message_ha_id in self.data
|
||||
and not self.data[event_message_ha_id].info.connected
|
||||
):
|
||||
self.data[event_message_ha_id].info.connected = True
|
||||
self._call_all_event_listeners_for_appliance(
|
||||
event_message_ha_id
|
||||
)
|
||||
match event_message.type:
|
||||
case EventType.STATUS:
|
||||
statuses = self.data[event_message_ha_id].status
|
||||
@@ -265,18 +256,20 @@ class HomeConnectCoordinator(
|
||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||
_LOGGER.debug(
|
||||
"Non-breaking error (%s) while listening for events,"
|
||||
" continuing in %s seconds",
|
||||
" continuing in 30 seconds",
|
||||
type(error).__name__,
|
||||
retry_time,
|
||||
)
|
||||
await asyncio.sleep(retry_time)
|
||||
retry_time = min(retry_time * 2, 3600)
|
||||
await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY)
|
||||
except HomeConnectApiError as error:
|
||||
_LOGGER.error("Error while listening for events: %s", error)
|
||||
self.hass.config_entries.async_schedule_reload(
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
break
|
||||
# if there was a non-breaking error, we continue listening
|
||||
# but we need to refresh the data to get the possible changes
|
||||
# that happened while the event stream was interrupted
|
||||
await self.async_refresh()
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage) -> None:
|
||||
@@ -304,8 +297,6 @@ class HomeConnectCoordinator(
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
except HomeConnectError as error:
|
||||
for appliance_data in self.data.values():
|
||||
appliance_data.info.connected = False
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_api_error",
|
||||
@@ -314,7 +305,7 @@ class HomeConnectCoordinator(
|
||||
|
||||
return {
|
||||
appliance.ha_id: await self._get_appliance_data(
|
||||
appliance, self.data.get(appliance.ha_id)
|
||||
appliance, self.data.get(appliance.ha_id) if self.data else None
|
||||
)
|
||||
for appliance in appliances.homeappliances
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import cast
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -52,10 +51,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_native_value()
|
||||
available = self._attr_available = self.appliance.info.connected
|
||||
self.async_write_ha_state()
|
||||
state = STATE_UNAVAILABLE if not available else self.state
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> str:
|
||||
@@ -64,13 +61,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available.
|
||||
|
||||
Do not use self.last_update_success for available state
|
||||
as event updates should take precedence over the coordinator
|
||||
refresh.
|
||||
"""
|
||||
return self._attr_available
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
self.appliance.info.connected and self._attr_available and super().available
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
"default": "mdi:map-marker-remove-variant"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"open_door": {
|
||||
"default": "mdi:door-open"
|
||||
},
|
||||
"partly_open_door": {
|
||||
"default": "mdi:door-open"
|
||||
},
|
||||
"pause_program": {
|
||||
"default": "mdi:pause"
|
||||
},
|
||||
"resume_program": {
|
||||
"default": "mdi:play"
|
||||
},
|
||||
"stop_program": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"operation_state": {
|
||||
"default": "mdi:state-machine",
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.16.2"],
|
||||
"requirements": ["aiohomeconnect==0.15.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
"options": {
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal",
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus"
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense +"
|
||||
}
|
||||
},
|
||||
"coffee_milk_ratio": {
|
||||
@@ -410,7 +410,7 @@
|
||||
"laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry",
|
||||
"laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry",
|
||||
"laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry",
|
||||
"laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus",
|
||||
"laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry +",
|
||||
"laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry"
|
||||
}
|
||||
},
|
||||
@@ -592,7 +592,7 @@
|
||||
"description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items."
|
||||
},
|
||||
"dishcare_dishwasher_option_vario_speed_plus": {
|
||||
"name": "Vario speed plus",
|
||||
"name": "Vario speed +",
|
||||
"description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying."
|
||||
},
|
||||
"dishcare_dishwasher_option_silence_on_demand": {
|
||||
@@ -608,7 +608,7 @@
|
||||
"description": "Defines if improved drying for glasses and plasticware is enabled."
|
||||
},
|
||||
"dishcare_dishwasher_option_hygiene_plus": {
|
||||
"name": "Hygiene plus",
|
||||
"name": "Hygiene +",
|
||||
"description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use."
|
||||
},
|
||||
"dishcare_dishwasher_option_eco_dry": {
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"inactive": "Inactive",
|
||||
"ready": "Ready",
|
||||
"delayedstart": "Delayed start",
|
||||
"run": "Run",
|
||||
"run": "Running",
|
||||
"pause": "[%key:common::state::paused%]",
|
||||
"actionrequired": "Action required",
|
||||
"finished": "Finished",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"brightness": {
|
||||
"default": "mdi:brightness-5"
|
||||
},
|
||||
"brightness_instance": {
|
||||
"default": "mdi:brightness-5"
|
||||
},
|
||||
"link_quality": {
|
||||
"default": "mdi:signal"
|
||||
},
|
||||
@@ -15,7 +9,7 @@
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"watchdog": {
|
||||
"watchdog_on_off": {
|
||||
"default": "mdi:dog"
|
||||
},
|
||||
"manual_operation": {
|
||||
|
||||
@@ -40,22 +40,10 @@ def get_window_value(attribute: HomeeAttribute) -> str | None:
|
||||
return vals.get(attribute.current_value)
|
||||
|
||||
|
||||
def get_brightness_device_class(
|
||||
attribute: HomeeAttribute, device_class: SensorDeviceClass | None
|
||||
) -> SensorDeviceClass | None:
|
||||
"""Return the device class for a brightness sensor."""
|
||||
if attribute.unit == "%":
|
||||
return None
|
||||
return device_class
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeeSensorEntityDescription(SensorEntityDescription):
|
||||
"""A class that describes Homee sensor entities."""
|
||||
|
||||
device_class_fn: Callable[
|
||||
[HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None
|
||||
] = lambda attribute, device_class: device_class
|
||||
value_fn: Callable[[HomeeAttribute], str | float | None] = (
|
||||
lambda value: value.current_value
|
||||
)
|
||||
@@ -79,7 +67,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
||||
AttributeType.BRIGHTNESS: HomeeSensorEntityDescription(
|
||||
key="brightness",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
device_class_fn=get_brightness_device_class,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=(
|
||||
lambda attribute: attribute.current_value * 1000
|
||||
@@ -316,9 +303,6 @@ class HomeeSensor(HomeeEntity, SensorEntity):
|
||||
if attribute.instance > 0:
|
||||
self._attr_translation_key = f"{self._attr_translation_key}_instance"
|
||||
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
|
||||
self._attr_device_class = description.device_class_fn(
|
||||
attribute, description.device_class
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str | None:
|
||||
|
||||
@@ -111,9 +111,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"brightness": {
|
||||
"name": "Illuminance"
|
||||
},
|
||||
"brightness_instance": {
|
||||
"name": "Illuminance {instance}"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,7 @@ class HKDevice:
|
||||
self._pending_subscribes: set[tuple[int, int]] = set()
|
||||
self._subscribe_timer: CALLBACK_TYPE | None = None
|
||||
self._load_platforms_lock = asyncio.Lock()
|
||||
self._full_update_requested: bool = False
|
||||
|
||||
@property
|
||||
def entity_map(self) -> Accessories:
|
||||
@@ -840,11 +841,48 @@ class HKDevice:
|
||||
|
||||
async def async_request_update(self, now: datetime | None = None) -> None:
|
||||
"""Request an debounced update from the accessory."""
|
||||
self._full_update_requested = True
|
||||
await self._debounced_update.async_call()
|
||||
|
||||
async def async_update(self, now: datetime | None = None) -> None:
|
||||
"""Poll state of all entities attached to this bridge/accessory."""
|
||||
to_poll = self.pollable_characteristics
|
||||
accessories = self.entity_map.accessories
|
||||
|
||||
if (
|
||||
not self._full_update_requested
|
||||
and len(accessories) == 1
|
||||
and self.available
|
||||
and not (to_poll - self.watchable_characteristics)
|
||||
and self.pairing.is_available
|
||||
and await self.pairing.controller.async_reachable(
|
||||
self.unique_id, timeout=5.0
|
||||
)
|
||||
):
|
||||
# If its a single accessory and all chars are watchable,
|
||||
# only poll the firmware version to keep the connection alive
|
||||
# https://github.com/home-assistant/core/issues/123412
|
||||
#
|
||||
# Firmware revision is used here since iOS does this to keep camera
|
||||
# connections alive, and the goal is to not regress
|
||||
# https://github.com/home-assistant/core/issues/116143
|
||||
# by polling characteristics that are not normally polled frequently
|
||||
# and may not be tested by the device vendor.
|
||||
#
|
||||
_LOGGER.debug(
|
||||
"Accessory is reachable, limiting poll to firmware version: %s",
|
||||
self.unique_id,
|
||||
)
|
||||
first_accessory = accessories[0]
|
||||
accessory_info = first_accessory.services.first(
|
||||
service_type=ServicesTypes.ACCESSORY_INFORMATION
|
||||
)
|
||||
assert accessory_info is not None
|
||||
firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
|
||||
to_poll = {(first_accessory.aid, firmware_iid)}
|
||||
|
||||
self._full_update_requested = False
|
||||
|
||||
if not to_poll:
|
||||
self.async_update_available_state()
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.8"],
|
||||
"requirements": ["aiohomekit==3.2.7"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
||||
@@ -94,12 +94,7 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
add_device(hue_resource)
|
||||
|
||||
# create/update all current devices found in controllers
|
||||
# sort the devices to ensure bridges are added first
|
||||
hue_devices = list(dev_controller)
|
||||
hue_devices.sort(
|
||||
key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
|
||||
)
|
||||
known_devices = [add_device(hue_device) for hue_device in hue_devices]
|
||||
known_devices = [add_device(hue_device) for hue_device in dev_controller]
|
||||
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
|
||||
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||
if self.custom_event_template is not None:
|
||||
try:
|
||||
data["custom"] = self.custom_event_template.async_render(
|
||||
data | {"text": message.text}, parse_result=True
|
||||
data, parse_result=True
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s",
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==0.7.1"]
|
||||
"requirements": ["inkbird-ble==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
@@ -141,7 +140,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, GetCurrentDateIntentHandler())
|
||||
intent.async_register(hass, GetCurrentTimeIntentHandler())
|
||||
intent.async_register(hass, RespondIntentHandler())
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
|
||||
return True
|
||||
|
||||
@@ -446,48 +444,6 @@ class RespondIntentHandler(intent.IntentHandler):
|
||||
return response
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = intent.INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {CLIMATE_DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name,
|
||||
area_name=area,
|
||||
domains=[CLIMATE_DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
async def _async_process_intent(
|
||||
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
|
||||
) -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
@@ -47,6 +47,7 @@ PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Support for waterheater entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from thinqconnect import DeviceType
|
||||
from thinqconnect.integration import ExtendedProperty
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
ATTR_OPERATION_MODE,
|
||||
STATE_ECO,
|
||||
STATE_HEAT_PUMP,
|
||||
STATE_OFF,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityDescription,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ThinqConfigEntry
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
from .entity import ThinQEntity
|
||||
|
||||
DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = {
|
||||
DeviceType.WATER_HEATER: WaterHeaterEntityDescription(
|
||||
key=ExtendedProperty.WATER_HEATER,
|
||||
name=None,
|
||||
),
|
||||
DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription(
|
||||
key=ExtendedProperty.WATER_BOILER,
|
||||
name=None,
|
||||
),
|
||||
}
|
||||
|
||||
# Mapping between device and HA operation modes
|
||||
DEVICE_OP_MODE_TO_HA = {
|
||||
"auto": STATE_ECO,
|
||||
"heat_pump": STATE_HEAT_PUMP,
|
||||
"turbo": STATE_PERFORMANCE,
|
||||
"vacation": STATE_OFF,
|
||||
}
|
||||
HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ThinqConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up an entry for water_heater platform."""
|
||||
entities: list[ThinQWaterHeaterEntity] = []
|
||||
for coordinator in entry.runtime_data.coordinators.values():
|
||||
if (
|
||||
description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type)
|
||||
) is not None:
|
||||
if coordinator.api.device.device_type == DeviceType.WATER_HEATER:
|
||||
entities.append(
|
||||
ThinQWaterHeaterEntity(
|
||||
coordinator, description, ExtendedProperty.WATER_HEATER
|
||||
)
|
||||
)
|
||||
elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER:
|
||||
entities.append(
|
||||
ThinQWaterBoilerEntity(
|
||||
coordinator, description, ExtendedProperty.WATER_BOILER
|
||||
)
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity):
|
||||
"""Represent a ThinQ water heater entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DeviceDataUpdateCoordinator,
|
||||
entity_description: WaterHeaterEntityDescription,
|
||||
property_id: str,
|
||||
) -> None:
|
||||
"""Initialize a water_heater entity."""
|
||||
super().__init__(coordinator, entity_description, property_id)
|
||||
self._attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
)
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
if modes := self.data.job_modes:
|
||||
self._attr_operation_list = [
|
||||
DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes
|
||||
]
|
||||
else:
|
||||
self._attr_operation_list = [STATE_HEAT_PUMP]
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself."""
|
||||
super()._update_status()
|
||||
self._attr_current_temperature = self.data.current_temp
|
||||
self._attr_target_temperature = self.data.target_temp
|
||||
|
||||
if self.data.max is not None:
|
||||
self._attr_max_temp = self.data.max
|
||||
if self.data.min is not None:
|
||||
self._attr_min_temp = self.data.min
|
||||
if self.data.step is not None:
|
||||
self._attr_target_temperature_step = self.data.step
|
||||
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
if self.data.is_on:
|
||||
self._attr_current_operation = (
|
||||
DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode)
|
||||
if (job_mode := self.data.job_mode) is not None
|
||||
else STATE_HEAT_PUMP
|
||||
)
|
||||
else:
|
||||
self._attr_current_operation = STATE_OFF
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
self.current_temperature,
|
||||
self.target_temperature,
|
||||
self.current_operation,
|
||||
self.operation_list,
|
||||
self.data.is_on,
|
||||
)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_temperature: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
kwargs,
|
||||
)
|
||||
if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None:
|
||||
await self.async_set_operation_mode(str(operation_mode))
|
||||
if operation_mode == STATE_OFF:
|
||||
return
|
||||
|
||||
if (
|
||||
temperature := kwargs.get(ATTR_TEMPERATURE)
|
||||
) is not None and temperature != self.target_temperature:
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_set_target_temperature(
|
||||
self.property_id, temperature
|
||||
)
|
||||
)
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set new operation mode."""
|
||||
mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode)
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_operation_mode: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
mode,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_set_job_mode(self.property_id, mode)
|
||||
)
|
||||
|
||||
|
||||
class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity):
|
||||
"""Represent a ThinQ water boiler entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DeviceDataUpdateCoordinator,
|
||||
entity_description: WaterHeaterEntityDescription,
|
||||
property_id: str,
|
||||
) -> None:
|
||||
"""Initialize a water_heater entity."""
|
||||
super().__init__(coordinator, entity_description, property_id)
|
||||
self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id
|
||||
)
|
||||
await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id
|
||||
)
|
||||
await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id))
|
||||
@@ -53,12 +53,12 @@
|
||||
},
|
||||
"services": {
|
||||
"set_hold_time": {
|
||||
"name": "Set Hold Time",
|
||||
"description": "Sets the time to hold until.",
|
||||
"name": "Set hold time",
|
||||
"description": "Sets the time period to keep the temperature and override the schedule.",
|
||||
"fields": {
|
||||
"time_period": {
|
||||
"name": "Time Period",
|
||||
"description": "Time to hold until."
|
||||
"name": "Time period",
|
||||
"description": "Duration for which to override the schedule."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.02.19"],
|
||||
"requirements": ["yt-dlp[default]==2025.01.26"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,14 +6,7 @@ from functools import lru_cache
|
||||
from types import TracebackType
|
||||
from typing import Self
|
||||
|
||||
from paho.mqtt.client import (
|
||||
CallbackOnConnect_v2,
|
||||
CallbackOnDisconnect_v2,
|
||||
CallbackOnPublish_v2,
|
||||
CallbackOnSubscribe_v2,
|
||||
CallbackOnUnsubscribe_v2,
|
||||
Client as MQTTClient,
|
||||
)
|
||||
from paho.mqtt.client import Client as MQTTClient
|
||||
|
||||
_MQTT_LOCK_COUNT = 7
|
||||
|
||||
@@ -51,12 +44,6 @@ class AsyncMQTTClient(MQTTClient):
|
||||
that is not needed since we are running in an async event loop.
|
||||
"""
|
||||
|
||||
on_connect: CallbackOnConnect_v2
|
||||
on_disconnect: CallbackOnDisconnect_v2
|
||||
on_publish: CallbackOnPublish_v2
|
||||
on_subscribe: CallbackOnSubscribe_v2
|
||||
on_unsubscribe: CallbackOnUnsubscribe_v2
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the client.
|
||||
|
||||
@@ -64,10 +51,10 @@ class AsyncMQTTClient(MQTTClient):
|
||||
since the client is running in an async event loop
|
||||
and will never run in multiple threads.
|
||||
"""
|
||||
self._in_callback_mutex = NullLock() # type: ignore[assignment]
|
||||
self._callback_mutex = NullLock() # type: ignore[assignment]
|
||||
self._msgtime_mutex = NullLock() # type: ignore[assignment]
|
||||
self._out_message_mutex = NullLock() # type: ignore[assignment]
|
||||
self._in_message_mutex = NullLock() # type: ignore[assignment]
|
||||
self._reconnect_delay_mutex = NullLock() # type: ignore[assignment]
|
||||
self._mid_generate_mutex = NullLock() # type: ignore[assignment]
|
||||
self._in_callback_mutex = NullLock()
|
||||
self._callback_mutex = NullLock()
|
||||
self._msgtime_mutex = NullLock()
|
||||
self._out_message_mutex = NullLock()
|
||||
self._in_message_mutex = NullLock()
|
||||
self._reconnect_delay_mutex = NullLock()
|
||||
self._mid_generate_mutex = NullLock()
|
||||
|
||||
@@ -15,6 +15,7 @@ import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import uuid
|
||||
|
||||
import certifi
|
||||
|
||||
@@ -116,7 +117,7 @@ MAX_UNSUBSCRIBES_PER_CALL = 500
|
||||
|
||||
MAX_PACKETS_TO_READ = 500
|
||||
|
||||
type SocketType = socket.socket | ssl.SSLSocket | mqtt._WebsocketWrapper | Any # noqa: SLF001
|
||||
type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any
|
||||
|
||||
type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None
|
||||
|
||||
@@ -298,39 +299,22 @@ class MqttClientSetup:
|
||||
from .async_client import AsyncMQTTClient
|
||||
|
||||
config = self._config
|
||||
clean_session: bool | None = None
|
||||
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
|
||||
proto = mqtt.MQTTv31
|
||||
clean_session = True
|
||||
elif protocol == PROTOCOL_5:
|
||||
proto = mqtt.MQTTv5
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
clean_session = True
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = None
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
self._client = AsyncMQTTClient(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=client_id,
|
||||
# See: https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
|
||||
# clean_session (bool defaults to None)
|
||||
# a boolean that determines the client type.
|
||||
# If True, the broker will remove all information about this client when it
|
||||
# disconnects. If False, the client is a persistent client and subscription
|
||||
# information and queued messages will be retained when the client
|
||||
# disconnects. Note that a client will never discard its own outgoing
|
||||
# messages on disconnect. Calling connect() or reconnect() will cause the
|
||||
# messages to be resent. Use reinitialise() to reset a client to its
|
||||
# original state. The clean_session argument only applies to MQTT versions
|
||||
# v3.1.1 and v3.1. It is not accepted if the MQTT version is v5.0 - use the
|
||||
# clean_start argument on connect() instead.
|
||||
clean_session=clean_session,
|
||||
client_id,
|
||||
protocol=proto,
|
||||
transport=transport, # type: ignore[arg-type]
|
||||
transport=transport,
|
||||
reconnect_on_failure=False,
|
||||
)
|
||||
self._client.setup()
|
||||
@@ -387,7 +371,6 @@ class MQTT:
|
||||
self.loop = hass.loop
|
||||
self.config_entry = config_entry
|
||||
self.conf = conf
|
||||
self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
|
||||
|
||||
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
|
||||
set
|
||||
@@ -493,9 +476,9 @@ class MQTT:
|
||||
mqttc.on_connect = self._async_mqtt_on_connect
|
||||
mqttc.on_disconnect = self._async_mqtt_on_disconnect
|
||||
mqttc.on_message = self._async_mqtt_on_message
|
||||
mqttc.on_publish = self._async_mqtt_on_publish
|
||||
mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe
|
||||
mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe
|
||||
mqttc.on_publish = self._async_mqtt_on_callback
|
||||
mqttc.on_subscribe = self._async_mqtt_on_callback
|
||||
mqttc.on_unsubscribe = self._async_mqtt_on_callback
|
||||
|
||||
# suppress exceptions at callback
|
||||
mqttc.suppress_exceptions = True
|
||||
@@ -515,7 +498,7 @@ class MQTT:
|
||||
def _async_reader_callback(self, client: mqtt.Client) -> None:
|
||||
"""Handle reading data from the socket."""
|
||||
if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0:
|
||||
self._async_handle_callback_exception(status)
|
||||
self._async_on_disconnect(status)
|
||||
|
||||
@callback
|
||||
def _async_start_misc_periodic(self) -> None:
|
||||
@@ -550,7 +533,7 @@ class MQTT:
|
||||
try:
|
||||
# Some operating systems do not allow us to set the preferred
|
||||
# buffer size. In that case we try some other size options.
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) # type: ignore[union-attr]
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size)
|
||||
except OSError as err:
|
||||
if new_buffer_size <= MIN_BUFFER_SIZE:
|
||||
_LOGGER.warning(
|
||||
@@ -610,7 +593,7 @@ class MQTT:
|
||||
def _async_writer_callback(self, client: mqtt.Client) -> None:
|
||||
"""Handle writing data to the socket."""
|
||||
if (status := client.loop_write()) != 0:
|
||||
self._async_handle_callback_exception(status)
|
||||
self._async_on_disconnect(status)
|
||||
|
||||
def _on_socket_register_write(
|
||||
self, client: mqtt.Client, userdata: Any, sock: SocketType
|
||||
@@ -669,25 +652,14 @@ class MQTT:
|
||||
result: int | None = None
|
||||
self._available_future = client_available
|
||||
self._should_reconnect = True
|
||||
connect_partial = partial(
|
||||
self._mqttc.connect,
|
||||
host=self.conf[CONF_BROKER],
|
||||
port=self.conf.get(CONF_PORT, DEFAULT_PORT),
|
||||
keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
|
||||
# See:
|
||||
# https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
|
||||
# `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or
|
||||
# `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag
|
||||
# always, never or on the first successful connect only,
|
||||
# respectively. MQTT session data (such as outstanding messages and
|
||||
# subscriptions) is cleared on successful connect when the
|
||||
# clean_start flag is set. For MQTT v3.1.1, the clean_session
|
||||
# argument of Client should be used for similar result.
|
||||
clean_start=True if self.is_mqttv5 else mqtt.MQTT_CLEAN_START_FIRST_ONLY,
|
||||
)
|
||||
try:
|
||||
async with self._connection_lock, self._async_connect_in_executor():
|
||||
result = await self.hass.async_add_executor_job(connect_partial)
|
||||
result = await self.hass.async_add_executor_job(
|
||||
self._mqttc.connect,
|
||||
self.conf[CONF_BROKER],
|
||||
self.conf.get(CONF_PORT, DEFAULT_PORT),
|
||||
self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
|
||||
)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
|
||||
self._async_connection_result(False)
|
||||
@@ -1011,9 +983,9 @@ class MQTT:
|
||||
self,
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
_flags: dict[str, int],
|
||||
result_code: int,
|
||||
properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""On connect callback.
|
||||
|
||||
@@ -1021,20 +993,19 @@ class MQTT:
|
||||
message.
|
||||
"""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
if reason_code.is_failure:
|
||||
# 24: Continue authentication
|
||||
# 25: Re-authenticate
|
||||
# 134: Bad user name or password
|
||||
# 135: Not authorized
|
||||
# 140: Bad authentication method
|
||||
if reason_code.value in (24, 25, 134, 135, 140):
|
||||
if result_code != mqtt.CONNACK_ACCEPTED:
|
||||
if result_code in (
|
||||
mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD,
|
||||
mqtt.CONNACK_REFUSED_NOT_AUTHORIZED,
|
||||
):
|
||||
self._should_reconnect = False
|
||||
self.hass.async_create_task(self.async_disconnect())
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
_LOGGER.error(
|
||||
"Unable to connect to the MQTT broker: %s",
|
||||
reason_code.getName(), # type: ignore[no-untyped-call]
|
||||
mqtt.connack_string(result_code),
|
||||
)
|
||||
self._async_connection_result(False)
|
||||
return
|
||||
@@ -1045,7 +1016,7 @@ class MQTT:
|
||||
"Connected to MQTT server %s:%s (%s)",
|
||||
self.conf[CONF_BROKER],
|
||||
self.conf.get(CONF_PORT, DEFAULT_PORT),
|
||||
reason_code,
|
||||
result_code,
|
||||
)
|
||||
|
||||
birth: dict[str, Any]
|
||||
@@ -1182,32 +1153,18 @@ class MQTT:
|
||||
self._mqtt_data.state_write_requests.process_write_state_requests(msg)
|
||||
|
||||
@callback
|
||||
def _async_mqtt_on_publish(
|
||||
def _async_mqtt_on_callback(
|
||||
self,
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
mid: int,
|
||||
_reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None,
|
||||
_granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None,
|
||||
_properties_reason: mqtt.ReasonCodes | None = None,
|
||||
) -> None:
|
||||
"""Publish callback."""
|
||||
self._async_mqtt_on_callback(mid)
|
||||
|
||||
@callback
|
||||
def _async_mqtt_on_subscribe_unsubscribe(
|
||||
self,
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
mid: int,
|
||||
_reason_code: list[mqtt.ReasonCode],
|
||||
_properties: mqtt.Properties | None,
|
||||
) -> None:
|
||||
"""Subscribe / Unsubscribe callback."""
|
||||
self._async_mqtt_on_callback(mid)
|
||||
|
||||
@callback
|
||||
def _async_mqtt_on_callback(self, mid: int) -> None:
|
||||
"""Publish / Subscribe / Unsubscribe callback."""
|
||||
# The callback signature for on_unsubscribe is different from on_subscribe
|
||||
# see https://github.com/eclipse/paho.mqtt.python/issues/687
|
||||
# properties and reason codes are not used in Home Assistant
|
||||
future = self._async_get_mid_future(mid)
|
||||
if future.done() and (future.cancelled() or future.exception()):
|
||||
# Timed out or cancelled
|
||||
@@ -1223,28 +1180,19 @@ class MQTT:
|
||||
self._pending_operations[mid] = future
|
||||
return future
|
||||
|
||||
@callback
|
||||
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
|
||||
"""Handle a callback exception."""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
_LOGGER.warning(
|
||||
"Error returned from MQTT server: %s",
|
||||
mqtt.error_string(status),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_mqtt_on_disconnect(
|
||||
self,
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_disconnect_flags: mqtt.DisconnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
result_code: int,
|
||||
properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Disconnected callback."""
|
||||
self._async_on_disconnect(result_code)
|
||||
|
||||
@callback
|
||||
def _async_on_disconnect(self, result_code: int) -> None:
|
||||
if not self.connected:
|
||||
# This function is re-entrant and may be called multiple times
|
||||
# when there is a broken pipe error.
|
||||
@@ -1255,11 +1203,11 @@ class MQTT:
|
||||
self.connected = False
|
||||
async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False)
|
||||
_LOGGER.log(
|
||||
logging.INFO if reason_code == 0 else logging.DEBUG,
|
||||
logging.INFO if result_code == 0 else logging.DEBUG,
|
||||
"Disconnected from MQTT server %s:%s (%s)",
|
||||
self.conf[CONF_BROKER],
|
||||
self.conf.get(CONF_PORT, DEFAULT_PORT),
|
||||
reason_code,
|
||||
result_code,
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -1268,9 +1216,7 @@ class MQTT:
|
||||
if not future.done():
|
||||
future.set_exception(asyncio.TimeoutError)
|
||||
|
||||
async def _async_wait_for_mid_or_raise(
|
||||
self, mid: int | None, result_code: int
|
||||
) -> None:
|
||||
async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None:
|
||||
"""Wait for ACK from broker or raise on error."""
|
||||
if result_code != 0:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
@@ -1286,8 +1232,6 @@ class MQTT:
|
||||
|
||||
# Create the mid event if not created, either _mqtt_handle_mid or
|
||||
# _async_wait_for_mid_or_raise may be executed first.
|
||||
if TYPE_CHECKING:
|
||||
assert mid is not None
|
||||
future = self._async_get_mid_future(mid)
|
||||
loop = self.hass.loop
|
||||
timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future)
|
||||
@@ -1325,7 +1269,7 @@ def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from paho.mqtt.matcher import MQTTMatcher
|
||||
|
||||
matcher = MQTTMatcher() # type: ignore[no-untyped-call]
|
||||
matcher = MQTTMatcher()
|
||||
matcher[subscription] = True
|
||||
|
||||
return lambda topic: next(matcher.iter_match(topic), False) # type: ignore[no-untyped-call]
|
||||
return lambda topic: next(matcher.iter_match(topic), False)
|
||||
|
||||
@@ -1023,14 +1023,14 @@ def try_connection(
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
client_: mqtt.Client,
|
||||
userdata: None,
|
||||
flags: dict[str, Any],
|
||||
result_code: int,
|
||||
properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
result.put(result_code == mqtt.CONNACK_ACCEPTED)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import lawn_mower
|
||||
from homeassistant.components.lawn_mower import (
|
||||
ENTITY_ID_FORMAT,
|
||||
LawnMowerActivity,
|
||||
LawnMowerEntity,
|
||||
LawnMowerEntityFeature,
|
||||
@@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic"
|
||||
CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template"
|
||||
|
||||
DEFAULT_NAME = "MQTT Lawn Mower"
|
||||
ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}"
|
||||
|
||||
MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@@ -217,10 +217,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
self._attr_color_mode = next(iter(self.supported_color_modes))
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.UNKNOWN
|
||||
elif config.get(CONF_BRIGHTNESS):
|
||||
# Brightness is supported and no supported_color_modes are set,
|
||||
# so set brightness as the supported color mode.
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def _update_color(self, values: dict[str, Any]) -> None:
|
||||
color_mode: str = values["color_mode"]
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mqtt",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["paho-mqtt==2.1.0"],
|
||||
"requirements": ["paho-mqtt==1.6.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
|
||||
from music_assistant_models.enums import EventType
|
||||
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
|
||||
from music_assistant_models.errors import MusicAssistantError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .actions import get_music_assistant_client, register_actions
|
||||
from .actions import register_actions
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -137,18 +137,6 @@ async def async_setup_entry(
|
||||
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
|
||||
)
|
||||
|
||||
# check if any playerconfigs have been removed while we were disconnected
|
||||
all_player_configs = await mass.config.get_player_configs()
|
||||
player_ids = {player.player_id for player in all_player_configs}
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
||||
for device in dev_entries:
|
||||
for identifier in device.identifiers:
|
||||
if identifier[0] == DOMAIN and identifier[1] not in player_ids:
|
||||
dev_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -186,31 +174,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await mass_entry_data.mass.disconnect()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
player_id = next(
|
||||
(
|
||||
identifier[1]
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
if player_id is None:
|
||||
# this should not be possible at all, but guard it anyways
|
||||
return False
|
||||
mass = get_music_assistant_client(hass, config_entry.entry_id)
|
||||
if mass.players.get(player_id) is None:
|
||||
# player is already removed on the server, this is an orphaned device
|
||||
return True
|
||||
# try to remove the player from the server
|
||||
try:
|
||||
await mass.config.remove_player_config(player_id)
|
||||
except ActionUnavailable:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@@ -23,7 +23,6 @@ from .const import (
|
||||
ATTR_ALBUM_TYPE,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_CONFIG_ENTRY_ID,
|
||||
ATTR_FAVORITE,
|
||||
ATTR_ITEMS,
|
||||
@@ -33,7 +32,6 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_RADIO,
|
||||
ATTR_SEARCH,
|
||||
ATTR_SEARCH_ALBUM,
|
||||
@@ -50,15 +48,7 @@ from .schemas import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_models.media_items import (
|
||||
Album,
|
||||
Artist,
|
||||
Audiobook,
|
||||
Playlist,
|
||||
Podcast,
|
||||
Radio,
|
||||
Track,
|
||||
)
|
||||
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
|
||||
@@ -165,14 +155,6 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.radio
|
||||
],
|
||||
ATTR_AUDIOBOOKS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.audiobooks
|
||||
],
|
||||
ATTR_PODCASTS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.podcasts
|
||||
],
|
||||
}
|
||||
)
|
||||
return response
|
||||
@@ -193,13 +175,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
|
||||
"order_by": order_by,
|
||||
}
|
||||
library_result: (
|
||||
list[Album]
|
||||
| list[Artist]
|
||||
| list[Track]
|
||||
| list[Radio]
|
||||
| list[Playlist]
|
||||
| list[Audiobook]
|
||||
| list[Podcast]
|
||||
list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist]
|
||||
)
|
||||
if media_type == MediaType.ALBUM:
|
||||
library_result = await mass.music.get_library_albums(
|
||||
@@ -223,14 +199,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
|
||||
library_result = await mass.music.get_library_playlists(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.AUDIOBOOK:
|
||||
library_result = await mass.music.get_library_audiobooks(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.PODCAST:
|
||||
library_result = await mass.music.get_library_podcasts(
|
||||
**base_params,
|
||||
)
|
||||
else:
|
||||
raise ServiceValidationError(f"Unsupported media type {media_type}")
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ ATTR_ARTISTS = "artists"
|
||||
ATTR_ALBUMS = "albums"
|
||||
ATTR_TRACKS = "tracks"
|
||||
ATTR_PLAYLISTS = "playlists"
|
||||
ATTR_AUDIOBOOKS = "audiobooks"
|
||||
ATTR_PODCASTS = "podcasts"
|
||||
ATTR_RADIO = "radio"
|
||||
ATTR_ITEMS = "items"
|
||||
ATTR_RADIO_MODE = "radio_mode"
|
||||
|
||||
@@ -15,7 +15,6 @@ from .const import (
|
||||
ATTR_ALBUM,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_BIT_DEPTH,
|
||||
ATTR_CONTENT_TYPE,
|
||||
ATTR_CURRENT_INDEX,
|
||||
@@ -32,7 +31,6 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_PROVIDER,
|
||||
ATTR_QUEUE_ID,
|
||||
ATTR_QUEUE_ITEM_ID,
|
||||
@@ -103,12 +101,6 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
|
||||
vol.Required(ATTR_RADIO): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_AUDIOBOOKS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_PODCASTS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -21,10 +21,7 @@ play_media:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- folder
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -121,9 +118,7 @@ search:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -165,9 +160,7 @@ get_library:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
favorite:
|
||||
|
||||
@@ -195,11 +195,8 @@
|
||||
"options": {
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"audiobook": "Audiobook",
|
||||
"folder": "Folder",
|
||||
"track": "Track",
|
||||
"playlist": "Playlist",
|
||||
"podcast": "Podcast",
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,8 +119,6 @@ class NSDepartureSensor(SensorEntity):
|
||||
self._time = time
|
||||
self._state = None
|
||||
self._trips = None
|
||||
self._first_trip = None
|
||||
self._next_trip = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -135,44 +133,44 @@ class NSDepartureSensor(SensorEntity):
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
if not self._trips or self._first_trip is None:
|
||||
if not self._trips:
|
||||
return None
|
||||
|
||||
if self._first_trip.trip_parts:
|
||||
route = [self._first_trip.departure]
|
||||
route.extend(k.destination for k in self._first_trip.trip_parts)
|
||||
if self._trips[0].trip_parts:
|
||||
route = [self._trips[0].departure]
|
||||
route.extend(k.destination for k in self._trips[0].trip_parts)
|
||||
|
||||
# Static attributes
|
||||
attributes = {
|
||||
"going": self._first_trip.going,
|
||||
"going": self._trips[0].going,
|
||||
"departure_time_planned": None,
|
||||
"departure_time_actual": None,
|
||||
"departure_delay": False,
|
||||
"departure_platform_planned": self._first_trip.departure_platform_planned,
|
||||
"departure_platform_actual": self._first_trip.departure_platform_actual,
|
||||
"departure_platform_planned": self._trips[0].departure_platform_planned,
|
||||
"departure_platform_actual": self._trips[0].departure_platform_actual,
|
||||
"arrival_time_planned": None,
|
||||
"arrival_time_actual": None,
|
||||
"arrival_delay": False,
|
||||
"arrival_platform_planned": self._first_trip.arrival_platform_planned,
|
||||
"arrival_platform_actual": self._first_trip.arrival_platform_actual,
|
||||
"arrival_platform_planned": self._trips[0].arrival_platform_planned,
|
||||
"arrival_platform_actual": self._trips[0].arrival_platform_actual,
|
||||
"next": None,
|
||||
"status": self._first_trip.status.lower(),
|
||||
"transfers": self._first_trip.nr_transfers,
|
||||
"status": self._trips[0].status.lower(),
|
||||
"transfers": self._trips[0].nr_transfers,
|
||||
"route": route,
|
||||
"remarks": None,
|
||||
}
|
||||
|
||||
# Planned departure attributes
|
||||
if self._first_trip.departure_time_planned is not None:
|
||||
attributes["departure_time_planned"] = (
|
||||
self._first_trip.departure_time_planned.strftime("%H:%M")
|
||||
)
|
||||
if self._trips[0].departure_time_planned is not None:
|
||||
attributes["departure_time_planned"] = self._trips[
|
||||
0
|
||||
].departure_time_planned.strftime("%H:%M")
|
||||
|
||||
# Actual departure attributes
|
||||
if self._first_trip.departure_time_actual is not None:
|
||||
attributes["departure_time_actual"] = (
|
||||
self._first_trip.departure_time_actual.strftime("%H:%M")
|
||||
)
|
||||
if self._trips[0].departure_time_actual is not None:
|
||||
attributes["departure_time_actual"] = self._trips[
|
||||
0
|
||||
].departure_time_actual.strftime("%H:%M")
|
||||
|
||||
# Delay departure attributes
|
||||
if (
|
||||
@@ -184,16 +182,16 @@ class NSDepartureSensor(SensorEntity):
|
||||
attributes["departure_delay"] = True
|
||||
|
||||
# Planned arrival attributes
|
||||
if self._first_trip.arrival_time_planned is not None:
|
||||
attributes["arrival_time_planned"] = (
|
||||
self._first_trip.arrival_time_planned.strftime("%H:%M")
|
||||
)
|
||||
if self._trips[0].arrival_time_planned is not None:
|
||||
attributes["arrival_time_planned"] = self._trips[
|
||||
0
|
||||
].arrival_time_planned.strftime("%H:%M")
|
||||
|
||||
# Actual arrival attributes
|
||||
if self._first_trip.arrival_time_actual is not None:
|
||||
attributes["arrival_time_actual"] = (
|
||||
self._first_trip.arrival_time_actual.strftime("%H:%M")
|
||||
)
|
||||
if self._trips[0].arrival_time_actual is not None:
|
||||
attributes["arrival_time_actual"] = self._trips[
|
||||
0
|
||||
].arrival_time_actual.strftime("%H:%M")
|
||||
|
||||
# Delay arrival attributes
|
||||
if (
|
||||
@@ -204,14 +202,15 @@ class NSDepartureSensor(SensorEntity):
|
||||
attributes["arrival_delay"] = True
|
||||
|
||||
# Next attributes
|
||||
if self._next_trip.departure_time_actual is not None:
|
||||
attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M")
|
||||
elif self._next_trip.departure_time_planned is not None:
|
||||
attributes["next"] = self._next_trip.departure_time_planned.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
else:
|
||||
attributes["next"] = None
|
||||
if len(self._trips) > 1:
|
||||
if self._trips[1].departure_time_actual is not None:
|
||||
attributes["next"] = self._trips[1].departure_time_actual.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
elif self._trips[1].departure_time_planned is not None:
|
||||
attributes["next"] = self._trips[1].departure_time_planned.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -226,7 +225,6 @@ class NSDepartureSensor(SensorEntity):
|
||||
):
|
||||
self._state = None
|
||||
self._trips = None
|
||||
self._first_trip = None
|
||||
return
|
||||
|
||||
# Set the search parameter to search from a specific trip time
|
||||
@@ -238,51 +236,19 @@ class NSDepartureSensor(SensorEntity):
|
||||
.strftime("%d-%m-%Y %H:%M")
|
||||
)
|
||||
else:
|
||||
trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M")
|
||||
trip_time = datetime.now().strftime("%d-%m-%Y %H:%M")
|
||||
|
||||
try:
|
||||
self._trips = self._nsapi.get_trips(
|
||||
trip_time, self._departure, self._via, self._heading, True, 0, 2
|
||||
)
|
||||
if self._trips:
|
||||
all_times = []
|
||||
|
||||
# If a train is delayed we can observe this through departure_time_actual.
|
||||
for trip in self._trips:
|
||||
if trip.departure_time_actual is None:
|
||||
all_times.append(trip.departure_time_planned)
|
||||
else:
|
||||
all_times.append(trip.departure_time_actual)
|
||||
|
||||
# Remove all trains that already left.
|
||||
filtered_times = [
|
||||
(i, time)
|
||||
for i, time in enumerate(all_times)
|
||||
if time > dt_util.now()
|
||||
]
|
||||
|
||||
if len(filtered_times) > 0:
|
||||
sorted_times = sorted(filtered_times, key=lambda x: x[1])
|
||||
self._first_trip = self._trips[sorted_times[0][0]]
|
||||
self._state = sorted_times[0][1].strftime("%H:%M")
|
||||
|
||||
# Filter again to remove trains that leave at the exact same time.
|
||||
filtered_times = [
|
||||
(i, time)
|
||||
for i, time in enumerate(all_times)
|
||||
if time > sorted_times[0][1]
|
||||
]
|
||||
|
||||
if len(filtered_times) > 0:
|
||||
sorted_times = sorted(filtered_times, key=lambda x: x[1])
|
||||
self._next_trip = self._trips[sorted_times[0][0]]
|
||||
else:
|
||||
self._next_trip = None
|
||||
|
||||
if self._trips[0].departure_time_actual is None:
|
||||
planned_time = self._trips[0].departure_time_planned
|
||||
self._state = planned_time.strftime("%H:%M")
|
||||
else:
|
||||
self._first_trip = None
|
||||
self._state = None
|
||||
|
||||
actual_time = self._trips[0].departure_time_actual
|
||||
self._state = actual_time.strftime("%H:%M")
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.HTTPError,
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==7.1.4"]
|
||||
"requirements": ["google-nest-sdm==7.1.3"]
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nexia",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nexia"],
|
||||
"requirements": ["nexia==2.1.1"]
|
||||
"requirements": ["nexia==2.0.9"]
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Diagnostics support for OneDrive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: OneDriveConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
data = {
|
||||
"drive": asdict(coordinator.data),
|
||||
"config": {
|
||||
**entry.data,
|
||||
**entry.options,
|
||||
},
|
||||
}
|
||||
|
||||
return async_redact_data(data, TO_REDACT)
|
||||
@@ -41,7 +41,10 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .util import async_migration_in_progress, get_instance
|
||||
@@ -14,8 +14,6 @@ async def async_pre_backup(hass: HomeAssistant) -> None:
|
||||
"""Perform operations before a backup starts."""
|
||||
_LOGGER.info("Backup start notification, locking database for writes")
|
||||
instance = get_instance(hass)
|
||||
if hass.state is not CoreState.running:
|
||||
raise HomeAssistantError("Home Assistant is not running")
|
||||
if async_migration_in_progress(hass):
|
||||
raise HomeAssistantError("Database migration in progress")
|
||||
await instance.lock_database()
|
||||
|
||||
@@ -30,12 +30,6 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check"
|
||||
MAX_QUEUE_BACKLOG_MIN_VALUE = 65000
|
||||
MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2
|
||||
|
||||
# As soon as we have more than 999 ids, split the query as the
|
||||
# MySQL optimizer handles it poorly and will no longer
|
||||
# do an index only scan with a group-by
|
||||
# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459
|
||||
MAX_IDS_FOR_INDEXED_GROUP_BY = 999
|
||||
|
||||
# The maximum number of rows (events) we purge in one delete statement
|
||||
|
||||
DEFAULT_MAX_BIND_VARS = 4000
|
||||
|
||||
@@ -6,12 +6,11 @@ from collections.abc import Callable, Iterable, Iterator
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
from operator import itemgetter
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import (
|
||||
CompoundSelect,
|
||||
Select,
|
||||
StatementLambdaElement,
|
||||
Subquery,
|
||||
and_,
|
||||
func,
|
||||
@@ -27,9 +26,8 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.recorder import get_instance
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.collection import chunked_or_all
|
||||
|
||||
from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY
|
||||
from ..const import LAST_REPORTED_SCHEMA_VERSION
|
||||
from ..db_schema import (
|
||||
SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
|
||||
StateAttributes,
|
||||
@@ -151,7 +149,6 @@ def _significant_states_stmt(
|
||||
no_attributes: bool,
|
||||
include_start_time_state: bool,
|
||||
run_start_ts: float | None,
|
||||
slow_dependent_subquery: bool,
|
||||
) -> Select | CompoundSelect:
|
||||
"""Query the database for significant state changes."""
|
||||
include_last_changed = not significant_changes_only
|
||||
@@ -190,7 +187,6 @@ def _significant_states_stmt(
|
||||
metadata_ids,
|
||||
no_attributes,
|
||||
include_last_changed,
|
||||
slow_dependent_subquery,
|
||||
).subquery(),
|
||||
no_attributes,
|
||||
include_last_changed,
|
||||
@@ -261,68 +257,7 @@ def get_significant_states_with_session(
|
||||
start_time_ts = start_time.timestamp()
|
||||
end_time_ts = datetime_to_timestamp_or_none(end_time)
|
||||
single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
|
||||
rows: list[Row] = []
|
||||
if TYPE_CHECKING:
|
||||
assert instance.database_engine is not None
|
||||
slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery
|
||||
if include_start_time_state and slow_dependent_subquery:
|
||||
# https://github.com/home-assistant/core/issues/137178
|
||||
# If we include the start time state we need to limit the
|
||||
# number of metadata_ids we query for at a time to avoid
|
||||
# hitting limits in the MySQL optimizer that prevent
|
||||
# the start time state query from using an index-only optimization
|
||||
# to find the start time state.
|
||||
iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY)
|
||||
else:
|
||||
iter_metadata_ids = (metadata_ids,)
|
||||
for metadata_ids_chunk in iter_metadata_ids:
|
||||
stmt = _generate_significant_states_with_session_stmt(
|
||||
start_time_ts,
|
||||
end_time_ts,
|
||||
single_metadata_id,
|
||||
metadata_ids_chunk,
|
||||
metadata_ids_in_significant_domains,
|
||||
significant_changes_only,
|
||||
no_attributes,
|
||||
include_start_time_state,
|
||||
oldest_ts,
|
||||
slow_dependent_subquery,
|
||||
)
|
||||
row_chunk = cast(
|
||||
list[Row],
|
||||
execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
|
||||
)
|
||||
if rows:
|
||||
rows += row_chunk
|
||||
else:
|
||||
# If we have no rows yet, we can just assign the chunk
|
||||
# as this is the common case since its rare that
|
||||
# we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
|
||||
rows = row_chunk
|
||||
return _sorted_states_to_dict(
|
||||
rows,
|
||||
start_time_ts if include_start_time_state else None,
|
||||
entity_ids,
|
||||
entity_id_to_metadata_id,
|
||||
minimal_response,
|
||||
compressed_state_format,
|
||||
no_attributes=no_attributes,
|
||||
)
|
||||
|
||||
|
||||
def _generate_significant_states_with_session_stmt(
|
||||
start_time_ts: float,
|
||||
end_time_ts: float | None,
|
||||
single_metadata_id: int | None,
|
||||
metadata_ids: list[int],
|
||||
metadata_ids_in_significant_domains: list[int],
|
||||
significant_changes_only: bool,
|
||||
no_attributes: bool,
|
||||
include_start_time_state: bool,
|
||||
oldest_ts: float | None,
|
||||
slow_dependent_subquery: bool,
|
||||
) -> StatementLambdaElement:
|
||||
return lambda_stmt(
|
||||
stmt = lambda_stmt(
|
||||
lambda: _significant_states_stmt(
|
||||
start_time_ts,
|
||||
end_time_ts,
|
||||
@@ -333,7 +268,6 @@ def _generate_significant_states_with_session_stmt(
|
||||
no_attributes,
|
||||
include_start_time_state,
|
||||
oldest_ts,
|
||||
slow_dependent_subquery,
|
||||
),
|
||||
track_on=[
|
||||
bool(single_metadata_id),
|
||||
@@ -342,9 +276,17 @@ def _generate_significant_states_with_session_stmt(
|
||||
significant_changes_only,
|
||||
no_attributes,
|
||||
include_start_time_state,
|
||||
slow_dependent_subquery,
|
||||
],
|
||||
)
|
||||
return _sorted_states_to_dict(
|
||||
execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
|
||||
start_time_ts if include_start_time_state else None,
|
||||
entity_ids,
|
||||
entity_id_to_metadata_id,
|
||||
minimal_response,
|
||||
compressed_state_format,
|
||||
no_attributes=no_attributes,
|
||||
)
|
||||
|
||||
|
||||
def get_full_significant_states_with_session(
|
||||
@@ -612,14 +554,13 @@ def get_last_state_changes(
|
||||
)
|
||||
|
||||
|
||||
def _get_start_time_state_for_entities_stmt_dependent_sub_query(
|
||||
def _get_start_time_state_for_entities_stmt(
|
||||
epoch_time: float,
|
||||
metadata_ids: list[int],
|
||||
no_attributes: bool,
|
||||
include_last_changed: bool,
|
||||
) -> Select:
|
||||
"""Baked query to get states for specific entities."""
|
||||
# Engine has a fast dependent subquery optimizer
|
||||
# This query is the result of significant research in
|
||||
# https://github.com/home-assistant/core/issues/132865
|
||||
# A reverse index scan with a limit 1 is the fastest way to get the
|
||||
@@ -629,9 +570,7 @@ def _get_start_time_state_for_entities_stmt_dependent_sub_query(
|
||||
# before a specific point in time for all entities.
|
||||
stmt = (
|
||||
_stmt_and_join_attributes_for_start_state(
|
||||
no_attributes=no_attributes,
|
||||
include_last_changed=include_last_changed,
|
||||
include_last_reported=False,
|
||||
no_attributes, include_last_changed, False
|
||||
)
|
||||
.select_from(StatesMeta)
|
||||
.join(
|
||||
@@ -661,55 +600,6 @@ def _get_start_time_state_for_entities_stmt_dependent_sub_query(
|
||||
)
|
||||
|
||||
|
||||
def _get_start_time_state_for_entities_stmt_group_by(
|
||||
epoch_time: float,
|
||||
metadata_ids: list[int],
|
||||
no_attributes: bool,
|
||||
include_last_changed: bool,
|
||||
) -> Select:
|
||||
"""Baked query to get states for specific entities."""
|
||||
# Simple group-by for MySQL, must use less
|
||||
# than 1000 metadata_ids in the IN clause for MySQL
|
||||
# or it will optimize poorly. Callers are responsible
|
||||
# for ensuring that the number of metadata_ids is less
|
||||
# than 1000.
|
||||
most_recent_states_for_entities_by_date = (
|
||||
select(
|
||||
States.metadata_id.label("max_metadata_id"),
|
||||
func.max(States.last_updated_ts).label("max_last_updated"),
|
||||
)
|
||||
.filter(
|
||||
(States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
|
||||
)
|
||||
.group_by(States.metadata_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
_stmt_and_join_attributes_for_start_state(
|
||||
no_attributes=no_attributes,
|
||||
include_last_changed=include_last_changed,
|
||||
include_last_reported=False,
|
||||
)
|
||||
.join(
|
||||
most_recent_states_for_entities_by_date,
|
||||
and_(
|
||||
States.metadata_id
|
||||
== most_recent_states_for_entities_by_date.c.max_metadata_id,
|
||||
States.last_updated_ts
|
||||
== most_recent_states_for_entities_by_date.c.max_last_updated,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
|
||||
)
|
||||
)
|
||||
if no_attributes:
|
||||
return stmt
|
||||
return stmt.outerjoin(
|
||||
StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
|
||||
)
|
||||
|
||||
|
||||
def _get_oldest_possible_ts(
|
||||
hass: HomeAssistant, utc_point_in_time: datetime
|
||||
) -> float | None:
|
||||
@@ -730,7 +620,6 @@ def _get_start_time_state_stmt(
|
||||
metadata_ids: list[int],
|
||||
no_attributes: bool,
|
||||
include_last_changed: bool,
|
||||
slow_dependent_subquery: bool,
|
||||
) -> Select:
|
||||
"""Return the states at a specific point in time."""
|
||||
if single_metadata_id:
|
||||
@@ -745,15 +634,7 @@ def _get_start_time_state_stmt(
|
||||
)
|
||||
# We have more than one entity to look at so we need to do a query on states
|
||||
# since the last recorder run started.
|
||||
if slow_dependent_subquery:
|
||||
return _get_start_time_state_for_entities_stmt_group_by(
|
||||
epoch_time,
|
||||
metadata_ids,
|
||||
no_attributes,
|
||||
include_last_changed,
|
||||
)
|
||||
|
||||
return _get_start_time_state_for_entities_stmt_dependent_sub_query(
|
||||
return _get_start_time_state_for_entities_stmt(
|
||||
epoch_time,
|
||||
metadata_ids,
|
||||
no_attributes,
|
||||
|
||||
@@ -37,13 +37,3 @@ class DatabaseOptimizer:
|
||||
# https://wiki.postgresql.org/wiki/Loose_indexscan
|
||||
# https://github.com/home-assistant/core/issues/126084
|
||||
slow_range_in_select: bool
|
||||
|
||||
# MySQL 8.x+ can end up with a file-sort on a dependent subquery
|
||||
# which makes the query painfully slow.
|
||||
# https://github.com/home-assistant/core/issues/137178
|
||||
# The solution is to use multiple indexed group-by queries instead
|
||||
# of the subquery as long as the group by does not exceed
|
||||
# 999 elements since as soon as we hit 1000 elements MySQL
|
||||
# will no longer use the group_index_range optimization.
|
||||
# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459
|
||||
slow_dependent_subquery: bool
|
||||
|
||||
@@ -28,7 +28,6 @@ from homeassistant.helpers.recorder import DATA_RECORDER
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.collection import chunked_or_all
|
||||
from homeassistant.util.unit_conversion import (
|
||||
AreaConverter,
|
||||
BaseUnitConverter,
|
||||
@@ -60,7 +59,6 @@ from .const import (
|
||||
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS,
|
||||
INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES,
|
||||
INTEGRATION_PLATFORM_VALIDATE_STATISTICS,
|
||||
MAX_IDS_FOR_INDEXED_GROUP_BY,
|
||||
SupportedDialect,
|
||||
)
|
||||
from .db_schema import (
|
||||
@@ -1671,7 +1669,6 @@ def _augment_result_with_change(
|
||||
drop_sum = "sum" not in _types
|
||||
prev_sums = {}
|
||||
if tmp := _statistics_at_time(
|
||||
get_instance(hass),
|
||||
session,
|
||||
{metadata[statistic_id][0] for statistic_id in result},
|
||||
table,
|
||||
@@ -2030,39 +2027,7 @@ def get_latest_short_term_statistics_with_session(
|
||||
)
|
||||
|
||||
|
||||
def _generate_statistics_at_time_stmt_group_by(
|
||||
table: type[StatisticsBase],
|
||||
metadata_ids: set[int],
|
||||
start_time_ts: float,
|
||||
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
|
||||
) -> StatementLambdaElement:
|
||||
"""Create the statement for finding the statistics for a given time."""
|
||||
# Simple group-by for MySQL, must use less
|
||||
# than 1000 metadata_ids in the IN clause for MySQL
|
||||
# or it will optimize poorly. Callers are responsible
|
||||
# for ensuring that the number of metadata_ids is less
|
||||
# than 1000.
|
||||
return _generate_select_columns_for_types_stmt(table, types) + (
|
||||
lambda q: q.join(
|
||||
most_recent_statistic_ids := (
|
||||
select(
|
||||
func.max(table.start_ts).label("max_start_ts"),
|
||||
table.metadata_id.label("max_metadata_id"),
|
||||
)
|
||||
.filter(table.start_ts < start_time_ts)
|
||||
.filter(table.metadata_id.in_(metadata_ids))
|
||||
.group_by(table.metadata_id)
|
||||
.subquery()
|
||||
),
|
||||
and_(
|
||||
table.start_ts == most_recent_statistic_ids.c.max_start_ts,
|
||||
table.metadata_id == most_recent_statistic_ids.c.max_metadata_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _generate_statistics_at_time_stmt_dependent_sub_query(
|
||||
def _generate_statistics_at_time_stmt(
|
||||
table: type[StatisticsBase],
|
||||
metadata_ids: set[int],
|
||||
start_time_ts: float,
|
||||
@@ -2076,7 +2041,8 @@ def _generate_statistics_at_time_stmt_dependent_sub_query(
|
||||
# databases. Since all databases support this query as a join
|
||||
# condition we can use it as a subquery to get the last start_time_ts
|
||||
# before a specific point in time for all entities.
|
||||
return _generate_select_columns_for_types_stmt(table, types) + (
|
||||
stmt = _generate_select_columns_for_types_stmt(table, types)
|
||||
stmt += (
|
||||
lambda q: q.select_from(StatisticsMeta)
|
||||
.join(
|
||||
table,
|
||||
@@ -2098,10 +2064,10 @@ def _generate_statistics_at_time_stmt_dependent_sub_query(
|
||||
)
|
||||
.where(table.metadata_id.in_(metadata_ids))
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def _statistics_at_time(
|
||||
instance: Recorder,
|
||||
session: Session,
|
||||
metadata_ids: set[int],
|
||||
table: type[StatisticsBase],
|
||||
@@ -2110,41 +2076,8 @@ def _statistics_at_time(
|
||||
) -> Sequence[Row] | None:
|
||||
"""Return last known statistics, earlier than start_time, for the metadata_ids."""
|
||||
start_time_ts = start_time.timestamp()
|
||||
if TYPE_CHECKING:
|
||||
assert instance.database_engine is not None
|
||||
if not instance.database_engine.optimizer.slow_dependent_subquery:
|
||||
stmt = _generate_statistics_at_time_stmt_dependent_sub_query(
|
||||
table=table,
|
||||
metadata_ids=metadata_ids,
|
||||
start_time_ts=start_time_ts,
|
||||
types=types,
|
||||
)
|
||||
return cast(list[Row], execute_stmt_lambda_element(session, stmt))
|
||||
rows: list[Row] = []
|
||||
# https://github.com/home-assistant/core/issues/132865
|
||||
# If we include the start time state we need to limit the
|
||||
# number of metadata_ids we query for at a time to avoid
|
||||
# hitting limits in the MySQL optimizer that prevent
|
||||
# the start time state query from using an index-only optimization
|
||||
# to find the start time state.
|
||||
for metadata_ids_chunk in chunked_or_all(
|
||||
metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY
|
||||
):
|
||||
stmt = _generate_statistics_at_time_stmt_group_by(
|
||||
table=table,
|
||||
metadata_ids=metadata_ids_chunk,
|
||||
start_time_ts=start_time_ts,
|
||||
types=types,
|
||||
)
|
||||
row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt))
|
||||
if rows:
|
||||
rows += row_chunk
|
||||
else:
|
||||
# If we have no rows yet, we can just assign the chunk
|
||||
# as this is the common case since its rare that
|
||||
# we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
|
||||
rows = row_chunk
|
||||
return rows
|
||||
stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types)
|
||||
return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt))
|
||||
|
||||
|
||||
def _build_sum_converted_stats(
|
||||
|
||||
@@ -464,7 +464,6 @@ def setup_connection_for_dialect(
|
||||
"""Execute statements needed for dialect connection."""
|
||||
version: AwesomeVersion | None = None
|
||||
slow_range_in_select = False
|
||||
slow_dependent_subquery = False
|
||||
if dialect_name == SupportedDialect.SQLITE:
|
||||
if first_connection:
|
||||
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
|
||||
@@ -506,8 +505,9 @@ def setup_connection_for_dialect(
|
||||
result = query_on_connection(dbapi_connection, "SELECT VERSION()")
|
||||
version_string = result[0][0]
|
||||
version = _extract_version_from_server_response(version_string)
|
||||
is_maria_db = "mariadb" in version_string.lower()
|
||||
|
||||
if "mariadb" in version_string.lower():
|
||||
if is_maria_db:
|
||||
if not version or version < MIN_VERSION_MARIA_DB:
|
||||
_raise_if_version_unsupported(
|
||||
version or version_string, "MariaDB", MIN_VERSION_MARIA_DB
|
||||
@@ -523,21 +523,19 @@ def setup_connection_for_dialect(
|
||||
instance.hass,
|
||||
version,
|
||||
)
|
||||
slow_range_in_select = bool(
|
||||
not version
|
||||
or version < MARIADB_WITH_FIXED_IN_QUERIES_105
|
||||
or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106
|
||||
or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107
|
||||
or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108
|
||||
)
|
||||
|
||||
elif not version or version < MIN_VERSION_MYSQL:
|
||||
_raise_if_version_unsupported(
|
||||
version or version_string, "MySQL", MIN_VERSION_MYSQL
|
||||
)
|
||||
else:
|
||||
# MySQL
|
||||
# https://github.com/home-assistant/core/issues/137178
|
||||
slow_dependent_subquery = True
|
||||
|
||||
slow_range_in_select = bool(
|
||||
not version
|
||||
or version < MARIADB_WITH_FIXED_IN_QUERIES_105
|
||||
or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106
|
||||
or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107
|
||||
or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108
|
||||
)
|
||||
|
||||
# Ensure all times are using UTC to avoid issues with daylight savings
|
||||
execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'")
|
||||
@@ -567,10 +565,7 @@ def setup_connection_for_dialect(
|
||||
return DatabaseEngine(
|
||||
dialect=SupportedDialect(dialect_name),
|
||||
version=version,
|
||||
optimizer=DatabaseOptimizer(
|
||||
slow_range_in_select=slow_range_in_select,
|
||||
slow_dependent_subquery=slow_dependent_subquery,
|
||||
),
|
||||
optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select),
|
||||
max_bind_vars=DEFAULT_MAX_BIND_VARS,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics(
|
||||
IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch)
|
||||
IPC_cam[ch]["encoding main"] = await api.get_encoding(ch)
|
||||
|
||||
chimes: dict[int, dict[str, Any]] = {}
|
||||
for chime in api.chime_list:
|
||||
chimes[chime.dev_id] = {}
|
||||
chimes[chime.dev_id]["channel"] = chime.channel
|
||||
chimes[chime.dev_id]["name"] = chime.name
|
||||
chimes[chime.dev_id]["online"] = chime.online
|
||||
chimes[chime.dev_id]["event_types"] = chime.chime_event_types
|
||||
|
||||
return {
|
||||
"model": api.model,
|
||||
"hardware version": api.hardware_version,
|
||||
@@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics(
|
||||
"channels": api.channels,
|
||||
"stream channels": api.stream_channels,
|
||||
"IPC cams": IPC_cam,
|
||||
"Chimes": chimes,
|
||||
"capabilities": api.capabilities,
|
||||
"cmd list": host.update_cmd,
|
||||
"firmware ch list": host.firmware_ch_list,
|
||||
"api versions": api.checked_api_versions,
|
||||
"abilities": api.abilities,
|
||||
"BC_abilities": api.baichuan.abilities,
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
translation_key="no_user_agreement",
|
||||
) from err
|
||||
except RoborockException as err:
|
||||
_LOGGER.debug("Failed to get Roborock home data: %s", err)
|
||||
raise ConfigEntryNotReady(
|
||||
"Failed to get Roborock home data",
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -179,7 +179,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
# Get the rooms for that map id.
|
||||
await self.set_current_map_rooms()
|
||||
except RoborockException as ex:
|
||||
_LOGGER.debug("Failed to update data: %s", ex)
|
||||
raise UpdateFailed(ex) from ex
|
||||
return self.roborock_device_info.props
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
import io
|
||||
import logging
|
||||
|
||||
from roborock import RoborockCommand
|
||||
from vacuum_map_parser_base.config.color import ColorsPalette
|
||||
@@ -31,8 +30,6 @@ from .const import (
|
||||
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockCoordinatedEntityV1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -51,11 +48,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
def parse_image(map_bytes: bytes) -> bytes | None:
|
||||
try:
|
||||
parsed_map = parser.parse(map_bytes)
|
||||
except (IndexError, ValueError) as err:
|
||||
_LOGGER.debug("Exception when parsing map contents: %s", err)
|
||||
return None
|
||||
parsed_map = parser.parse(map_bytes)
|
||||
if parsed_map.image is None:
|
||||
return None
|
||||
img_byte_arr = io.BytesIO()
|
||||
@@ -157,7 +150,6 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
|
||||
not isinstance(response[0], bytes)
|
||||
or (content := self.parser(response[0])) is None
|
||||
):
|
||||
_LOGGER.debug("Failed to parse map contents: %s", response[0])
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="map_failure",
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sense",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["sense_energy"],
|
||||
"requirements": ["sense-energy==0.13.6"]
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
}
|
||||
|
||||
@@ -130,10 +130,9 @@ async def async_setup_entry(
|
||||
"""Handle additions of devices and sensors."""
|
||||
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
|
||||
nonlocal added_devices
|
||||
new_devices, remove_devices, new_added_devices = coordinator.get_devices(
|
||||
new_devices, remove_devices, added_devices = coordinator.get_devices(
|
||||
added_devices
|
||||
)
|
||||
added_devices = new_added_devices
|
||||
|
||||
if LOGGER.isEnabledFor(logging.DEBUG):
|
||||
LOGGER.debug(
|
||||
@@ -169,7 +168,8 @@ async def async_setup_entry(
|
||||
device_data.model, DEVICE_SENSOR_TYPES
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
|
||||
_add_remove_devices()
|
||||
|
||||
@@ -46,8 +46,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -149,8 +149,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -56,31 +56,18 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
|
||||
) -> tuple[set[str], set[str], set[str]]:
|
||||
"""Addition and removal of devices."""
|
||||
data = self.data
|
||||
current_motion_sensors = {
|
||||
motion_sensors = {
|
||||
sensor_id
|
||||
for device_data in data.parsed.values()
|
||||
if device_data.motion_sensors
|
||||
for sensor_id in device_data.motion_sensors
|
||||
}
|
||||
current_devices: set[str] = set(data.parsed)
|
||||
LOGGER.debug(
|
||||
"Current devices: %s, moption sensors: %s",
|
||||
current_devices,
|
||||
current_motion_sensors,
|
||||
)
|
||||
new_devices: set[str] = (
|
||||
current_motion_sensors | current_devices
|
||||
) - added_devices
|
||||
remove_devices = added_devices - current_devices - current_motion_sensors
|
||||
new_added_devices = (added_devices - remove_devices) | new_devices
|
||||
devices: set[str] = set(data.parsed)
|
||||
new_devices: set[str] = motion_sensors | devices - added_devices
|
||||
remove_devices = added_devices - devices - motion_sensors
|
||||
added_devices = (added_devices - remove_devices) | new_devices
|
||||
|
||||
LOGGER.debug(
|
||||
"New devices: %s, Removed devices: %s, Added devices: %s",
|
||||
new_devices,
|
||||
remove_devices,
|
||||
new_added_devices,
|
||||
)
|
||||
return (new_devices, remove_devices, new_added_devices)
|
||||
return (new_devices, remove_devices, added_devices)
|
||||
|
||||
async def _async_update_data(self) -> SensiboData:
|
||||
"""Fetch data from Sensibo."""
|
||||
|
||||
@@ -76,8 +76,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -115,8 +115,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -253,8 +253,9 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, remove_devices, added_devices = coordinator.get_devices(
|
||||
added_devices
|
||||
)
|
||||
|
||||
if new_devices:
|
||||
entities.extend(
|
||||
|
||||
@@ -89,8 +89,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -56,8 +56,7 @@ async def async_setup_entry(
|
||||
def _add_remove_devices() -> None:
|
||||
"""Handle additions of devices and sensors."""
|
||||
nonlocal added_devices
|
||||
new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
|
||||
added_devices = new_added_devices
|
||||
new_devices, _, added_devices = coordinator.get_devices(added_devices)
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"requirements": ["aioshelly==13.1.0"],
|
||||
"requirements": ["aioshelly==13.0.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -21,14 +21,13 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA
|
||||
from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,20 +123,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
for device_entry in device_entries:
|
||||
device_id = next(
|
||||
identifier[1]
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
)
|
||||
if device_id in entry.runtime_data.devices:
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -345,8 +345,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
)
|
||||
self._attr_hvac_modes = self._determine_hvac_modes()
|
||||
self._attr_preset_modes = self._determine_preset_modes()
|
||||
if self.supports_capability(Capability.FAN_OSCILLATION_MODE):
|
||||
self._attr_swing_modes = self._determine_swing_modes()
|
||||
self._attr_swing_modes = self._determine_swing_modes()
|
||||
self._attr_supported_features = self._determine_supported_features()
|
||||
|
||||
def _determine_supported_features(self) -> ClimateEntityFeature:
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES
|
||||
from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,23 +30,10 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": " ".join(REQUESTED_SCOPES)}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Check we have the cloud integration set up."""
|
||||
if "cloud" not in self.hass.config.components:
|
||||
return self.async_abort(
|
||||
reason="cloud_not_enabled",
|
||||
description_placeholders={"default_config": "default_config"},
|
||||
)
|
||||
return await super().async_step_user(user_input)
|
||||
return {"scope": " ".join(SCOPES)}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for SmartThings."""
|
||||
if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES):
|
||||
return self.async_abort(reason="missing_scopes")
|
||||
client = SmartThings(session=async_get_clientsession(self.hass))
|
||||
client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
locations = await client.get_locations()
|
||||
|
||||
@@ -14,13 +14,9 @@ SCOPES = [
|
||||
"x:scenes:*",
|
||||
"r:rules:*",
|
||||
"w:rules:*",
|
||||
"sse",
|
||||
]
|
||||
|
||||
REQUESTED_SCOPES = [
|
||||
*SCOPES,
|
||||
"r:installedapps",
|
||||
"w:installedapps",
|
||||
"sse",
|
||||
]
|
||||
|
||||
CONF_APP_ID = "app_id"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pysmartthings import (
|
||||
Attribute,
|
||||
@@ -44,22 +44,19 @@ class SmartThingsEntity(Entity):
|
||||
identifiers={(DOMAIN, device.device.device_id)},
|
||||
name=device.device.label,
|
||||
)
|
||||
if (ocf := device.device.ocf) is not None:
|
||||
if (ocf := device.status[MAIN].get(Capability.OCF)) is not None:
|
||||
self._attr_device_info.update(
|
||||
{
|
||||
"manufacturer": ocf.manufacturer_name,
|
||||
"model": ocf.model_number.split("|")[0],
|
||||
"hw_version": ocf.hardware_version,
|
||||
"sw_version": ocf.firmware_version,
|
||||
}
|
||||
)
|
||||
if (viper := device.device.viper) is not None:
|
||||
self._attr_device_info.update(
|
||||
{
|
||||
"manufacturer": viper.manufacturer_name,
|
||||
"model": viper.model_name,
|
||||
"hw_version": viper.hardware_version,
|
||||
"sw_version": viper.software_version,
|
||||
"manufacturer": cast(
|
||||
str | None, ocf[Attribute.MANUFACTURER_NAME].value
|
||||
),
|
||||
"model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value),
|
||||
"hw_version": cast(
|
||||
str | None, ocf[Attribute.HARDWARE_VERSION].value
|
||||
),
|
||||
"sw_version": cast(
|
||||
str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings
|
||||
from pysmartthings import Attribute, Capability, Command, SmartThings
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
@@ -20,7 +19,6 @@ from homeassistant.components.light import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
@@ -55,7 +53,7 @@ def convert_scale(
|
||||
return round(value * target_scale / value_scale, round_digits)
|
||||
|
||||
|
||||
class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
class SmartThingsLight(SmartThingsEntity, LightEntity):
|
||||
"""Define a SmartThings Light."""
|
||||
|
||||
_attr_name = None
|
||||
@@ -86,28 +84,18 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
color_modes = set()
|
||||
if self.supports_capability(Capability.COLOR_TEMPERATURE):
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
if self.supports_capability(Capability.COLOR_CONTROL):
|
||||
color_modes.add(ColorMode.HS)
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL):
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if not color_modes:
|
||||
color_modes.add(ColorMode.ONOFF)
|
||||
if len(color_modes) == 1:
|
||||
self._attr_color_mode = list(color_modes)[0]
|
||||
self._attr_supported_color_modes = color_modes
|
||||
features = LightEntityFeature(0)
|
||||
if self.supports_capability(Capability.SWITCH_LEVEL):
|
||||
features |= LightEntityFeature.TRANSITION
|
||||
self._attr_supported_features = features
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if (last_state := await self.async_get_last_extra_data()) is not None:
|
||||
self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE]
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
tasks = []
|
||||
@@ -207,14 +195,17 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
argument=[level, duration],
|
||||
)
|
||||
|
||||
def _update_handler(self, event: DeviceEvent) -> None:
|
||||
"""Handle device updates."""
|
||||
if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE):
|
||||
self._attr_color_mode = {
|
||||
Capability.COLOR_CONTROL: ColorMode.HS,
|
||||
Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP,
|
||||
}[cast(Capability, event.capability)]
|
||||
super()._update_handler(event)
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if len(self._attr_supported_color_modes) == 1:
|
||||
# The light supports only a single color mode
|
||||
return list(self._attr_supported_color_modes)[0]
|
||||
|
||||
# The light supports hs + color temp, determine which one it is
|
||||
if self._attr_hs_color and self._attr_hs_color[1]:
|
||||
return ColorMode.HS
|
||||
return ColorMode.COLOR_TEMP
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smartthings",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"requirements": ["pysmartthings==2.5.0"]
|
||||
"requirements": ["pysmartthings==2.0.1"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user