Compare commits

..

41 Commits

Author SHA1 Message Date
jbouwh 1edea46a4d Revert "Bump paho-mqtt client to version 2.1.0 (#136130)"
This reverts commit 7fa6f7e875.
2025-02-28 08:56:10 +00:00
jbouwh c476e92bdc Revert " Upgrade paho-mqtt API to v2 (#137613)"
This reverts commit d6b7762dd6.
2025-02-28 08:52:41 +00:00
jbouwh 8dcd9945e8 Revert "Set clean_start=True on connect to MQTT broker (#136026)"
This reverts commit f8ffbf0506.
2025-02-28 08:52:08 +00:00
Joost Lekkerkerker 6953c20a65 Set SmartThings suggested display precision (#139470) 2025-02-28 09:15:13 +01:00
Ivan Lopez Hernandez 4e8186491c Fix Gemini Schema validation for #139416 (#139478)
Fixed Schema validation for issue #139477
2025-02-27 19:10:42 -08:00
rappenze 6fa93edf27 Bump pyfibaro to 0.8.2 (#139471) 2025-02-27 22:27:18 +00:00
Joost Lekkerkerker ef13b35c35 Only lowercase SmartThings media input source if we have it (#139468) 2025-02-27 21:50:34 +00:00
J. Nick Koston 0afdd9556f Bump aioesphomeapi to 29.3.1 (#139465) 2025-02-27 21:45:13 +00:00
J. Nick Koston e11ead410b Add coverage to ensure we do not load base platforms before recorder (#139464) 2025-02-27 20:50:23 +00:00
Norbert Rittel ef7058f703 Improve descriptions of lyric.set_hold_time action and field (#139385)
* Fix misleading descriptions on lyric.set_hold_time action

While on Honeywell Lyric thermostats the user can set a "Hold Until" time of day, the set_hold_time action does define a time period instead (Example: 01:00:00)

Therefore both descriptions are incorrectly using "until" for explaining the purpose of the action itself and the `time_period` field. 

This commit re-words both and adds some additional context that helps users (and translators) better understand this action and its purpose.

In addition the action name is changed to proper sentence-casing.

* Replace "time" with "duration" for additional clarity
2025-02-27 22:47:20 +02:00
Josef Zweck 938855bea3 Improve onedrive migration (#139458) 2025-02-27 20:42:04 +01:00
Joost Lekkerkerker 4c00c56afd Bump pysmartthings to 2.0.1 (#139454) 2025-02-27 21:30:18 +02:00
Simone Chemelli 8cc7e7b76f Full test coverage for Vodafone Station init (#139451)
Full test coverage for Vodafone Station init
2025-02-27 20:07:12 +01:00
J. Diego Rodríguez Royo df006aeade Bump aiohomeconnect to 0.15.1 (#139445) 2025-02-27 19:23:46 +01:00
Joost Lekkerkerker ffac522554 Fix SmartThings diagnostics (#139447) 2025-02-27 19:39:18 +02:00
starkillerOG 9502dbee56 Add more diagnostic info to Reolink (#139436)
* Add diagnostic info

* Bump reolink-aio to 0.12.1

* Add tests
2025-02-27 19:39:01 +02:00
J. Nick Koston a339fbaa82 Bump aioesphomeapi to 29.3.0 (#139441) 2025-02-27 16:56:30 +00:00
Bram Kragten b02eaed6b0 Update frontend to 20250227.0 (#139437) 2025-02-27 16:42:08 +01:00
Joost Lekkerkerker df594748cf Bump ruff to 0.9.8 (#139434) 2025-02-27 15:00:24 +00:00
Paulus Schoutsen 744a7a0e82 Fix conversation agent fallback (#139421) 2025-02-27 15:51:40 +01:00
Joost Lekkerkerker f677b910a6 Add diagnostics to SmartThings (#139423) 2025-02-27 15:23:25 +01:00
Michael Arthur 0da6b28808 Add lawn mower entity id format (#139402)
* add missing entity id format

* use ENTITY_ID_FORMAT in mqtt lawn mower
2025-02-27 15:02:14 +01:00
Marcel van der Veldt f111a2c34a Fix Music Assistant media player entity features (#139428)
* Fix Music Assistant supported media player features

* Update supported features when player config changes

* Add tests
2025-02-27 15:30:29 +02:00
starkillerOG 59eb323f8d Bump reolink-aio to 0.12.1 (#139427) 2025-02-27 15:29:57 +02:00
Joost Lekkerkerker 7ae13a4d72 Bump pysmartthings to 2.0.0 (#139418)
* Bump pysmartthings to 2.0.0

* Fix

* Fix

* Fix

* Fix
2025-02-27 13:25:55 +01:00
J. Nick Koston 735b843f5e Bump bleak-esphome to 2.8.0 (#139426) 2025-02-27 12:22:43 +00:00
J. Nick Koston 5b1783e859 Bump habluetooth to 3.24.1 (#139420) 2025-02-27 11:41:27 +00:00
LG-ThinQ-Integration 7b14b6af0e Add water heater entity to LG ThinQ (#138257)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2025-02-27 11:03:44 +00:00
J. Diego Rodríguez Royo cc18ec2de8 Fix fetch options error for Home connect (#139392)
* Handle errors when obtaining options definitions

* Don't fetch program options if the program key is unknown

* Test to ensure that available program endpoint is not called on unknown program
2025-02-27 12:00:14 +01:00
Josef Zweck df59adf5d1 Add reconfiguration to azure_storage (#139414)
* Add reauthentication to azure_storage

* Add reconfigure to azure_storage

* iqs

* update string

* ruff
2025-02-27 11:06:03 +01:00
dependabot[bot] 8c98cede60 Bump actions/attest-build-provenance from 2.2.0 to 2.2.1 (#139406)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-27 10:44:50 +01:00
dependabot[bot] b1a70c86c3 Bump docker/build-push-action from 6.14.0 to 6.15.0 (#139407)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-27 10:44:13 +01:00
dependabot[bot] 63daed0ed6 Bump codecov/codecov-action from 5.3.1 to 5.4.0 (#139408)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-27 10:43:13 +01:00
Josef Zweck 2150a668b0 Add reauthentication to azure_storage (#139411)
* Add reauthentication to azure_storage

* update docstring
2025-02-27 10:17:57 +01:00
Josef Zweck b505722f38 Bump onedrive to 0.0.12 (#139410)
* Bump onedrive to 0.0.12

* Add alternative name
2025-02-27 10:00:50 +01:00
puddly 036eef2b6b Bump ZHA to 0.0.51 (#139383)
* Bump ZHA to 0.0.51

* Fix unit tests not accounting for primary entities
2025-02-26 22:22:08 +02:00
Michael Hansen f3fb7cd8e8 Bump intents to 2025.2.26 (#139387) 2025-02-26 20:14:03 +00:00
J. Diego Rodríguez Royo 42f55bf271 Small improvements to Home Connect strings and icons (#139386)
* Small improvements to Home Connect strings and icons

* Fix test
2025-02-26 21:02:00 +01:00
Erik Montnemery 6d7dad41d9 Bump hatasmota to 0.10.0 (#139382) 2025-02-26 21:31:45 +02:00
fwestenberg 9dbce6d904 Bump stookwijzer==1.6.1 (#139380) 2025-02-26 21:31:24 +02:00
Bram Kragten 7f0db3181d Bump version to 2025.4.0 (#139381) 2025-02-26 19:54:29 +01:00
208 changed files with 1674 additions and 8156 deletions
+3 -3
View File
@@ -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 }}
+3 -3
View File
@@ -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 }}
+38 -2
View File
@@ -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 -1
View File
@@ -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:
+2 -3
View File
@@ -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": {
+3 -24
View File
@@ -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"
+42 -1
View File
@@ -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
+15 -51
View File
@@ -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"]
}
+2 -7
View File
@@ -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
+2 -4
View File
@@ -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"]
}
-1
View File
@@ -20,4 +20,3 @@ MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
UPDATE_INTERVAL = 60
MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2
+4 -10
View File
@@ -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",
+2 -1
View File
@@ -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 -7
View File
@@ -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": {
-16
View File
@@ -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."]
}
+1 -6
View File
@@ -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]
+1 -1
View File
@@ -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))
+4 -4
View File
@@ -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
}
+8 -21
View File
@@ -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()
+44 -100
View File
@@ -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)
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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%]",
+1 -1
View File
@@ -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: |
+1 -3
View File
@@ -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(
+12 -17
View File
@@ -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
+1 -9
View File
@@ -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",
+1 -1
View File
@@ -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()
+1 -2
View File
@@ -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(
+1 -2
View File
@@ -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."""
+1 -2
View File
@@ -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(
+1 -2
View File
@@ -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(
+3 -2
View File
@@ -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(
+1 -2
View File
@@ -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(
+1 -2
View File
@@ -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"
+12 -15
View File
@@ -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
),
}
)
+14 -23
View File
@@ -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