Compare commits

..

52 Commits

Author SHA1 Message Date
Nikhil Deepak 1cb5e31901 Pass keep_alive parameter to Ollama AI Task calls (#165410)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-23 00:16:56 +02:00
Thijs W. 4f414d0035 Fix duplicate-configuration edge-case in Frontier Silicon config flow (#172916) 2026-06-23 00:11:51 +02:00
Franck Nijhof 30b648ea6a Bump holidays to 0.99 (#174501) 2026-06-23 00:10:33 +02:00
fdebrus 18a259718f Vistapool: add reauthentication flow (#172825)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 00:09:33 +02:00
fdebrus 45de0f4b4a Add select platform to Vistapool (#172547)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 00:08:14 +02:00
Erwin Douna 940bf5bf09 Portainer fix type narrowing (#173040) 2026-06-23 00:07:34 +02:00
starkillerOG 07e78b6dbf Bump reolink-aio to 0.21.2 (#174497) 2026-06-23 00:06:47 +02:00
Franck Nijhof abcb677b57 Bump SQLAlchemy to 2.0.51 (#174499) 2026-06-22 23:59:00 +02:00
Ermanno Baschiera 7e638f9d0c Bump Helty Flow to silver quality scale (#173132)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:57:21 +02:00
Thomas D 8aca6115f4 Trigger location update on certain events for the Volvo integration (#172651)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 23:53:42 +02:00
Oscar Calvo dd688986f1 Add CCM15 swing control (#173793)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 23:51:53 +02:00
Branden Cash c519b7ba07 Populate hourly statistics in srp_energy (#167371)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Signed-off-by: Branden Cash <203336+ammmze@users.noreply.github.com>
2026-06-22 23:51:33 +02:00
Pete Sage 158595464a Cancel timers for Sonos on shutdown/reload (#172830) 2026-06-22 23:47:27 +02:00
Franck Nijhof f81aff0a69 Remove runtime_data dependency from SIA options flow (#174489) 2026-06-22 23:43:46 +02:00
Arie Catsman 9f90e6ec22 Bump pyenphase to v3.0.0 (#174496) 2026-06-23 00:41:23 +03:00
Simone Chemelli 31b311e17a Improve availability of notify enttities for Alexa Devices (#174220)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 23:37:27 +02:00
Manu 7e07f2ab87 Add notify entities to Notifications for Android TV / Fire TV (#169087) 2026-06-22 23:36:45 +02:00
Przemko92 312f4c8c35 Add new values for Compit climate (#174238)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 23:29:01 +02:00
Brett Adams ddeeba5f87 Bump Splunk to silver quality scale (#174236)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:20:16 +02:00
Przemko92 82a043bc47 Add new values for Compit sensor (#174240) 2026-06-22 23:17:32 +02:00
Alain 629b9a5f9b SwitchBot cloud fix webhook handling (#169141)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 23:16:22 +02:00
tacopiek 9400c2e40e Add energy_today sensor to LG ThinQ (#172983)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:16:07 +02:00
epenet 8a57bb0640 Refactor thermopro tests to avoid thermopro_sensor.async_setup_entry (#173880) 2026-06-22 23:14:39 +02:00
Chris ab8f1bf88e Add OpenEVSE button platform (#172964) 2026-06-22 23:14:27 +02:00
Erwin Douna b61ad539d9 MELCloud Home add frost and overheat protection (#174224) 2026-06-22 23:13:57 +02:00
Franck Nijhof 283dcee830 Fix Rachio calendar error when no events are scheduled (#173624) 2026-06-22 23:13:02 +02:00
Franck Nijhof e61a3ac684 Bump pyAtome to 0.1.2 (#173902) 2026-06-22 23:11:31 +02:00
Franck Nijhof 5595ba12fb Bump kiwiki-client to 0.1.2 (#173903)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-22 23:09:51 +02:00
Mick Vleeshouwer 1bfd4d1500 Handle ApplicationNotAllowedError in Overkiz cloud config flow (#174498) 2026-06-22 23:03:51 +02:00
Manu 249c3bb5dd Refactor sensor platform of Steam integration (#174415) 2026-06-22 22:59:42 +02:00
Ariel Ebersberger 982fe7a370 Use value comparison for value-based enums (#174494) 2026-06-22 22:44:26 +02:00
EnjoyingM 035e7e0a38 Wolflink Shared and multidevice hub support (#172795)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 22:40:14 +02:00
J. Nick Koston f6b0036ccc Bump zeroconf to 0.150.0 (#174484) 2026-06-22 22:29:17 +02:00
Denis Shulyaka b2a26afb14 Promote Anthropic IQS to Gold (#170268) 2026-06-22 22:20:39 +02:00
Mick Vleeshouwer 231470f4dc Configure RTS command duration on the Overkiz client (#174448) 2026-06-22 22:04:11 +02:00
Erwin Douna 8aeb5d63ab MELCloud Home add icon for holiday mode (#174459) 2026-06-22 22:02:10 +02:00
some-random-climber d613591463 Use service helper in Picnic (#174271) 2026-06-22 21:59:34 +02:00
Abílio Costa dc6269f52d Add override decorator to remaining homeassistant dir files (#174488) 2026-06-22 21:56:03 +02:00
some-random-climber 31690ac2d3 Use service helper in Volvo (#174267) 2026-06-22 21:54:54 +02:00
Tim Laing ffe008c6b2 Fix iCloud auth bug (#173816) 2026-06-22 21:52:47 +02:00
Raphael Hehl dc48c3e30f Fix UniFi Protect package detection via public events websocket (#173733) 2026-06-22 14:45:20 -05:00
Jonathan Swoboda 448fd04137 Convert epoch to datetime for ESPHome uptime sensor device class (#174223) 2026-06-22 21:30:31 +02:00
AlCalzone 41e7837bcf Set openSenseMap to bronze on the quality scale (#173864)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 21:29:59 +02:00
Mattie 951062fa82 Add SG Ready select entity to Qube heat pump (#170114)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 21:29:25 +02:00
Allen Porter f2ac5a4153 Support Roborock dynamic devices and simplify startup error handling (#173704) 2026-06-22 21:27:29 +02:00
Franck Nijhof 12832fe3cd Remove invalid state_class from System Monitor battery_empty sensor (#174487) 2026-06-22 21:09:46 +02:00
some-random-climber d35e58fdc7 Move service registration to async_setup in cloudflare (#174131) 2026-06-22 20:56:48 +02:00
smartcircuits 3570cd0ee3 Add reauthentication flow to WattWächter Plus (#174281)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:56:22 +02:00
Brett Adams 5a99cb4cca Fix Teslemetry rear seat heater entity availability (#174248)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-22 20:54:55 +02:00
Abílio Costa d49a9c4780 Add override decorator to components S to T (#174483) 2026-06-22 20:52:27 +02:00
Abílio Costa c330a983e4 Add override decorator to components U to Z (#174482) 2026-06-22 20:51:13 +02:00
Legendberg e47f58362a Add per-day sleep schedule entities for Litter-Robot 5 (#173569)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-22 20:48:51 +02:00
1754 changed files with 18439 additions and 2935 deletions
Generated
+2 -2
View File
@@ -1712,8 +1712,8 @@ CLAUDE.md @home-assistant/core
/tests/components/sql/ @gjohansson-ST @dougiteixeira
/homeassistant/components/squeezebox/ @rajlaud @pssc @peteS-UK
/tests/components/squeezebox/ @rajlaud @pssc @peteS-UK
/homeassistant/components/srp_energy/ @briglx
/tests/components/srp_energy/ @briglx
/homeassistant/components/srp_energy/ @briglx @ammmze
/tests/components/srp_energy/ @briglx @ammmze
/homeassistant/components/starline/ @anonym-tsk
/tests/components/starline/ @anonym-tsk
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
+3 -1
View File
@@ -6,7 +6,7 @@ from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import time
from typing import Any, cast
from typing import Any, cast, override
import jwt
@@ -109,6 +109,7 @@ class AuthManagerFlowManager(
super().__init__(hass)
self.auth_manager = auth_manager
@override
async def async_create_flow(
self,
handler_key: tuple[str, str],
@@ -122,6 +123,7 @@ class AuthManagerFlowManager(
raise KeyError(f"Unknown auth provider {handler_key}")
return await auth_provider.async_login_flow(context)
@override
async def async_finish_flow(
self,
flow: FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
+1
View File
@@ -39,6 +39,7 @@ class _PyJWSWithLoadCache(PyJWS):
# We only ever have a global instance of this class
# so we do not have to worry about the LRU growing
# each time we create a new instance.
@override
def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]:
"""Load a JWS."""
return super()._load(jwt)
@@ -1,6 +1,6 @@
"""Example auth module."""
from typing import Any
from typing import Any, override
import voluptuous as vol
@@ -35,6 +35,7 @@ class InsecureExampleModule(MultiFactorAuthModule):
self._data = config["data"]
@property
@override
def input_schema(self) -> vol.Schema:
"""Validate login flow input data."""
return vol.Schema({vol.Required("pin"): str})
@@ -44,6 +45,7 @@ class InsecureExampleModule(MultiFactorAuthModule):
"""Validate async_setup_user input data."""
return vol.Schema({vol.Required("pin"): str})
@override
async def async_setup_flow(self, user_id: str) -> SetupFlow:
"""Return a data entry flow handler for setup module.
@@ -51,6 +53,7 @@ class InsecureExampleModule(MultiFactorAuthModule):
"""
return SetupFlow(self, self.setup_schema, user_id)
@override
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
"""Set up user to use mfa module."""
# data shall has been validate in caller
@@ -64,6 +67,7 @@ class InsecureExampleModule(MultiFactorAuthModule):
self._data.append({"user_id": user_id, "pin": pin})
@override
async def async_depose_user(self, user_id: str) -> None:
"""Remove user from mfa module."""
found = None
@@ -74,10 +78,12 @@ class InsecureExampleModule(MultiFactorAuthModule):
if found:
self._data.remove(found)
@override
async def async_is_user_setup(self, user_id: str) -> bool:
"""Return whether user is setup."""
return any(data["user_id"] == user_id for data in self._data)
@override
async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool:
"""Return True if validation passed."""
return any(
+8 -1
View File
@@ -5,7 +5,7 @@ Sending HOTP through notify service
import asyncio
import logging
from typing import Any, cast
from typing import Any, cast, override
import attr
import voluptuous as vol
@@ -107,6 +107,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
self._init_lock = asyncio.Lock()
@property
@override
def input_schema(self) -> vol.Schema:
"""Validate login flow input data."""
return vol.Schema({vol.Required(INPUT_FIELD_CODE): str})
@@ -159,6 +160,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
return sorted(unordered_services)
@override
async def async_setup_flow(self, user_id: str) -> NotifySetupFlow:
"""Return a data entry flow handler for setup module.
@@ -168,6 +170,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
self, self.input_schema, user_id, self.aync_get_available_notify_services()
)
@override
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
"""Set up auth module for user."""
if self._user_settings is None:
@@ -181,6 +184,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
await self._async_save()
@override
async def async_depose_user(self, user_id: str) -> None:
"""Depose auth module for user."""
if self._user_settings is None:
@@ -190,6 +194,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
if self._user_settings.pop(user_id, None):
await self._async_save()
@override
async def async_is_user_setup(self, user_id: str) -> bool:
"""Return whether user is setup."""
if self._user_settings is None:
@@ -198,6 +203,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
return user_id in self._user_settings
@override
async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool:
"""Return True if validation passed."""
if self._user_settings is None:
@@ -283,6 +289,7 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
self._notify_service: str | None = None
self._target: str | None = None
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
+8 -1
View File
@@ -2,7 +2,7 @@
import asyncio
from io import BytesIO
from typing import Any, cast
from typing import Any, cast, override
import voluptuous as vol
@@ -87,6 +87,7 @@ class TotpAuthModule(MultiFactorAuthModule):
self._init_lock = asyncio.Lock()
@property
@override
def input_schema(self) -> vol.Schema:
"""Validate login flow input data."""
return vol.Schema({vol.Required(INPUT_FIELD_CODE): str})
@@ -115,6 +116,7 @@ class TotpAuthModule(MultiFactorAuthModule):
self._users[user_id] = ota_secret # type: ignore[index]
return ota_secret
@override
async def async_setup_flow(self, user_id: str) -> TotpSetupFlow:
"""Return a data entry flow handler for setup module.
@@ -124,6 +126,7 @@ class TotpAuthModule(MultiFactorAuthModule):
assert user is not None
return TotpSetupFlow(self, self.input_schema, user)
@override
async def async_setup_user(self, user_id: str, setup_data: Any) -> str:
"""Set up auth module for user."""
if self._users is None:
@@ -136,6 +139,7 @@ class TotpAuthModule(MultiFactorAuthModule):
await self._async_save()
return result
@override
async def async_depose_user(self, user_id: str) -> None:
"""Depose auth module for user."""
if self._users is None:
@@ -144,6 +148,7 @@ class TotpAuthModule(MultiFactorAuthModule):
if self._users.pop(user_id, None): # type: ignore[union-attr]
await self._async_save()
@override
async def async_is_user_setup(self, user_id: str) -> bool:
"""Return whether user is setup."""
if self._users is None:
@@ -151,6 +156,7 @@ class TotpAuthModule(MultiFactorAuthModule):
return user_id in self._users # type: ignore[operator]
@override
async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool:
"""Return True if validation passed."""
if self._users is None:
@@ -189,6 +195,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
super().__init__(auth_module, setup_schema, user.id)
self._user = user
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
+6 -1
View File
@@ -1,7 +1,7 @@
"""Permissions for Home Assistant."""
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, override
import voluptuous as vol
@@ -68,14 +68,17 @@ class PolicyPermissions(AbstractPermissions):
self._policy = policy
self._perm_lookup = perm_lookup
@override
def access_all_entities(self, key: str) -> bool:
"""Check if we have a certain access to all entities."""
return test_all(self._policy.get(CAT_ENTITIES), key)
@override
def _entity_func(self) -> Callable[[str, str], bool]:
"""Return a function that can test entity access."""
return compile_entities(self._policy.get(CAT_ENTITIES), self._perm_lookup)
@override
def __eq__(self, other: object) -> bool:
"""Equals check."""
return isinstance(other, PolicyPermissions) and other._policy == self._policy
@@ -84,10 +87,12 @@ class PolicyPermissions(AbstractPermissions):
class _OwnerPermissions(AbstractPermissions):
"""Owner permissions."""
@override
def access_all_entities(self, key: str) -> bool:
"""Check if we have a certain access to all entities."""
return True
@override
def _entity_func(self) -> Callable[[str, str], bool]:
"""Return a function that can test entity access."""
return lambda entity_id, key: True
+5 -1
View File
@@ -4,7 +4,7 @@ import asyncio
from collections.abc import Mapping
import logging
import os
from typing import Any
from typing import Any, override
import voluptuous as vol
@@ -57,6 +57,7 @@ class CommandLineAuthProvider(AuthProvider):
super().__init__(*args, **kwargs)
self._user_meta: dict[str, dict[str, Any]] = {}
@override
async def async_login_flow(
self, context: AuthFlowContext | None
) -> CommandLineLoginFlow:
@@ -105,6 +106,7 @@ class CommandLineAuthProvider(AuthProvider):
meta[key] = value
self._user_meta[username] = meta
@override
async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str]
) -> Credentials:
@@ -117,6 +119,7 @@ class CommandLineAuthProvider(AuthProvider):
# Create new credentials.
return self.async_create_credentials({"username": username})
@override
async def async_user_meta_for_credentials(
self, credentials: Credentials
) -> UserMeta:
@@ -136,6 +139,7 @@ class CommandLineAuthProvider(AuthProvider):
class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]):
"""Handler for the login flow."""
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> AuthFlowResult:
@@ -4,7 +4,7 @@ import asyncio
import base64
from collections.abc import Mapping
import logging
from typing import Any, cast
from typing import Any, cast, override
import bcrypt
import voluptuous as vol
@@ -302,6 +302,7 @@ class HassAuthProvider(AuthProvider):
self.data: Data | None = None
self._init_lock = asyncio.Lock()
@override
async def async_initialize(self) -> None:
"""Initialize the auth provider."""
async with self._init_lock:
@@ -312,6 +313,7 @@ class HassAuthProvider(AuthProvider):
await data.async_load()
self.data = data
@override
async def async_login_flow(self, context: AuthFlowContext | None) -> HassLoginFlow:
"""Return a flow to login."""
return HassLoginFlow(self)
@@ -369,6 +371,7 @@ class HassAuthProvider(AuthProvider):
)
await self.data.async_save()
@override
async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str]
) -> Credentials:
@@ -387,6 +390,7 @@ class HassAuthProvider(AuthProvider):
# Create new credentials.
return self.async_create_credentials({"username": username})
@override
async def async_user_meta_for_credentials(
self, credentials: Credentials
) -> UserMeta:
@@ -410,6 +414,7 @@ class HassAuthProvider(AuthProvider):
class HassLoginFlow(LoginFlow[HassAuthProvider]):
"""Handler for the login flow."""
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> AuthFlowResult:
@@ -2,6 +2,7 @@
from collections.abc import Mapping
import hmac
from typing import override
import voluptuous as vol
@@ -33,6 +34,7 @@ class InvalidAuthError(HomeAssistantError):
class ExampleAuthProvider(AuthProvider):
"""Example auth provider based on hardcoded usernames and passwords."""
@override
async def async_login_flow(
self, context: AuthFlowContext | None
) -> ExampleLoginFlow:
@@ -61,6 +63,7 @@ class ExampleAuthProvider(AuthProvider):
):
raise InvalidAuthError
@override
async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str]
) -> Credentials:
@@ -74,6 +77,7 @@ class ExampleAuthProvider(AuthProvider):
# Create new credentials.
return self.async_create_credentials({"username": username})
@override
async def async_user_meta_for_credentials(
self, credentials: Credentials
) -> UserMeta:
@@ -95,6 +99,7 @@ class ExampleAuthProvider(AuthProvider):
class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]):
"""Handler for the login flow."""
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> AuthFlowResult:
@@ -13,7 +13,7 @@ from ipaddress import (
ip_address,
ip_network,
)
from typing import Any, cast
from typing import Any, cast, override
import voluptuous as vol
@@ -98,10 +98,12 @@ class TrustedNetworksAuthProvider(AuthProvider):
]
@property
@override
def support_mfa(self) -> bool:
"""Trusted Networks auth provider does not support MFA."""
return False
@override
async def async_login_flow(
self, context: AuthFlowContext | None
) -> TrustedNetworksLoginFlow:
@@ -144,6 +146,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
self.config[CONF_ALLOW_BYPASS_LOGIN],
)
@override
async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str]
) -> Credentials:
@@ -172,6 +175,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
# We only allow login as exist user
raise InvalidUserError
@override
async def async_user_meta_for_credentials(
self, credentials: Credentials
) -> UserMeta:
@@ -203,6 +207,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
raise InvalidAuthError("Can't allow access from Home Assistant Cloud")
@callback
@override
def async_validate_refresh_token(
self, refresh_token: RefreshToken, remote_ip: str | None = None
) -> None:
@@ -230,6 +235,7 @@ class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]):
self._ip_address = ip_addr
self._allow_bypass_login = allow_bypass_login
@override
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> AuthFlowResult:
+2 -1
View File
@@ -14,7 +14,7 @@ import platform
import sys
import threading
from time import monotonic
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
# Import cryptography early since import openssl is not thread-safe
# _frozen_importlib._DeadlockError: deadlock detected by
@@ -697,6 +697,7 @@ def _create_log_file(
class _RotatingFileHandlerWithoutShouldRollOver(RotatingFileHandler):
"""RotatingFileHandler that does not check if it should roll over on every log."""
@override
def shouldRollover(self, record: logging.LogRecord) -> bool:
"""Never roll over.
@@ -23,6 +23,7 @@ class AmazonNotifyEntityDescription(NotifyEntityDescription):
"""Alexa Devices notify entity description."""
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
is_available_fn: Callable[[AmazonDevice], bool] = lambda _device: True
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
subkey: str
@@ -39,6 +40,9 @@ NOTIFY: Final = (
key="announce",
translation_key="announce",
subkey="AUDIO_PLAYER",
is_available_fn=lambda device: (
device.communication_settings.get("communications") != "OFF"
),
method=lambda api, device, message: api.call_alexa_announcement(
device, message
),
@@ -79,6 +83,13 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(self.device) and super().available
)
@override
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
@@ -1,6 +1,6 @@
"""Platform for Alexa To-do integration."""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, override
from aioamazondevices.structures import (
AmazonListInfo,
@@ -88,6 +88,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
)
@property
@override
def todo_items(self) -> list[TodoItem]:
"""Return all to-do items in the list."""
@@ -104,6 +105,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
for item in todo_items
]
@override
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list."""
_LOGGER.debug(
@@ -124,6 +126,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
self._list.name,
)
@override
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete items from the to-do list."""
_LOGGER.debug("Called async_delete_todo_items for %s item(s)", len(uids))
@@ -150,6 +153,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
existing_item.version,
)
@override
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item in the To-do list."""
list_items_lookup = self.coordinator.todo_list_items[self._list.id]
@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["anthropic==0.108.0"]
}
@@ -4,10 +4,7 @@ rules:
status: exempt
comment: |
Integration has no actions.
appropriate-polling:
status: exempt
comment: |
Integration does not poll.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
@@ -210,6 +210,7 @@ class AqvifyAggrDataCoordinator(
end_time = base_time.replace(minute=59).strftime(date_time_fmt)
return beg_time, end_time
@override
async def _async_update_data(self) -> dict[str, AqvifyHourAggregatedValues]:
"""Fetch device state."""
devices = self.config_entry.runtime_data.coordinator.data.devices
@@ -171,6 +171,7 @@ class AqvifyAggrSensor(AqvifyAggrEntity, SensorEntity):
entity_description: AqvifySensorAggrEntityDescription
@property
@override
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data[self.device_key])
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyatome"],
"quality_scale": "legacy",
"requirements": ["pyAtome==0.1.1"]
"requirements": ["pyAtome==0.1.2"]
}
@@ -174,6 +174,12 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Set the fan mode."""
await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the swing mode."""
await self.coordinator.async_set_swing_mode(
self._ac_index, self.data, swing_mode
)
@override
async def async_turn_off(self) -> None:
"""Turn off."""
+10 -2
View File
@@ -4,10 +4,10 @@ import datetime
import logging
from typing import override
from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice
from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice, TriState
import httpx
from homeassistant.components.climate import HVACMode
from homeassistant.components.climate import SWING_ON, HVACMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
@@ -86,6 +86,14 @@ class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
data.fan_mode = CONST_FAN_CMD_MAP[fan_mode]
await self.async_set_state(ac_index, data)
async def async_set_swing_mode(
self, ac_index: int, data: CCM15SlaveDevice, swing_mode: str
) -> None:
"""Set the swing mode."""
_LOGGER.debug("Set Swing[%s]='%s'", ac_index, swing_mode)
data.desired_swing = TriState.ON if swing_mode == SWING_ON else TriState.OFF
await self.async_set_state(ac_index, data)
async def async_set_temperature(
self,
ac_index: int,
@@ -1,9 +1,20 @@
"""Update the IP addresses of your Cloudflare DNS records."""
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SERVICE_UPDATE_RECORDS
from .const import DOMAIN
from .coordinator import CloudflareConfigEntry, CloudflareCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Cloudflare."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -> bool:
@@ -14,13 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -
# Since we are not using coordinator for data reads, we need to add dummy listener
entry.async_on_unload(entry.runtime_data.async_add_listener(lambda: None))
async def update_records_service(_: ServiceCall) -> None:
"""Set up service for manual trigger."""
await entry.runtime_data.async_request_refresh()
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service)
return True
@@ -0,0 +1,25 @@
"""Services for cloudflare."""
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import service
from .const import DOMAIN, SERVICE_UPDATE_RECORDS
from .coordinator import CloudflareConfigEntry
async def update_records_service(call: ServiceCall) -> None:
"""Set up service for manual trigger."""
entry: CloudflareConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, None
)
await entry.runtime_data.async_request_refresh()
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services."""
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_RECORDS,
update_records_service,
)
+89 -75
View File
@@ -1,16 +1,15 @@
"""Module contains the CompitClimate class for controlling climate entities."""
from dataclasses import dataclass
import logging
from typing import Any, override
from compit_inext_api import Parameter
from compit_inext_api.consts import (
CompitFanMode,
CompitHVACMode,
CompitParameter,
CompitPresetMode,
)
from propcache.api import cached_property
from homeassistant.components.climate import (
FAN_AUTO,
@@ -23,6 +22,7 @@ from homeassistant.components.climate import (
PRESET_HOME,
PRESET_NONE,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
HVACMode,
)
@@ -38,10 +38,19 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Device class for climate devices in Compit system
CLIMATE_DEVICE_CLASS = 10
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class CompitDeviceDescription(ClimateEntityDescription):
"""Class to describe a Compit climate entity."""
supported_features: ClimateEntityFeature
available_presets: list[str]
available_fan_modes: list[str]
available_hvac_modes: list[HVACMode]
COMPIT_MODE_MAP = {
CompitHVACMode.COOL: HVACMode.COOL,
CompitHVACMode.HEAT: HVACMode.HEAT,
@@ -68,6 +77,55 @@ HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
224: CompitDeviceDescription(
key="R 900",
supported_features=ClimateEntityFeature.PRESET_MODE,
available_presets=[PRESET_HOME, PRESET_AWAY],
available_fan_modes=[],
available_hvac_modes=[HVACMode.HEAT, HVACMode.OFF],
),
223: CompitDeviceDescription(
key="Nano Color 2",
supported_features=ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE,
available_presets=[PRESET_HOME, PRESET_ECO, PRESET_NONE, PRESET_AWAY],
available_fan_modes=[FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH],
available_hvac_modes=[
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.OFF,
],
),
12: CompitDeviceDescription(
key="Nano Color",
supported_features=ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE,
available_presets=[PRESET_HOME, PRESET_ECO, PRESET_NONE, PRESET_AWAY],
available_fan_modes=[FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH],
available_hvac_modes=[
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.OFF,
],
),
7: CompitDeviceDescription(
key="Nano One",
supported_features=ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE,
available_presets=[PRESET_HOME, PRESET_ECO, PRESET_NONE, PRESET_AWAY],
available_fan_modes=[FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH],
available_hvac_modes=[
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.OFF,
],
),
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -77,64 +135,49 @@ async def async_setup_entry(
"""Set up the CompitClimate platform from a config entry."""
coordinator = entry.runtime_data
climate_entities = []
for device_id in coordinator.connector.all_devices:
device = coordinator.connector.all_devices[device_id]
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
climate_entities.append(
CompitClimate(
coordinator,
device_id,
{
parameter.parameter_code: parameter
for parameter in device.definition.parameters
},
device.definition.name,
)
)
async_add_devices(climate_entities)
async_add_devices(
CompitClimate(
coordinator,
device_id,
device_definition,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
)
class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
"""Representation of a Compit climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
_attr_name = None
_attr_has_entity_name = True
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
)
entity_description: CompitDeviceDescription
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
parameters: dict[str, Parameter],
device_name: str,
entity_description: CompitDeviceDescription,
) -> None:
"""Initialize the climate device."""
super().__init__(coordinator)
self._attr_unique_id = f"{device_name}_{device_id}"
self._attr_unique_id = f"{entity_description.key}_{device_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
name=entity_description.key,
manufacturer=MANUFACTURER_NAME,
model=device_name,
model=entity_description.key,
)
self.parameters = parameters
self.device_id = device_id
self.available_presets: Parameter | None = self.parameters.get(
CompitParameter.PRESET_MODE.value
)
self.available_fan_modes: Parameter | None = self.parameters.get(
CompitParameter.FAN_MODE.value
)
self.entity_description = entity_description
self._attr_supported_features = entity_description.supported_features
self._attr_preset_modes = entity_description.available_presets
self._attr_fan_modes = entity_description.available_fan_modes
self._attr_hvac_modes = [
HVACMode(mode) for mode in entity_description.available_hvac_modes
]
@property
@override
@@ -163,38 +206,6 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
return None
return float(value)
@cached_property
@override
def preset_modes(self) -> list[str] | None:
"""Return the available preset modes."""
if self.available_presets is None or self.available_presets.details is None:
return []
preset_modes = []
for item in self.available_presets.details:
if item is not None:
ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
if ha_preset and ha_preset not in preset_modes:
preset_modes.append(ha_preset)
return preset_modes
@cached_property
@override
def fan_modes(self) -> list[str] | None:
"""Return the available fan modes."""
if self.available_fan_modes is None or self.available_fan_modes.details is None:
return []
fan_modes = []
for item in self.available_fan_modes.details:
if item is not None:
ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
if ha_fan_mode and ha_fan_mode not in fan_modes:
fan_modes.append(ha_fan_mode)
return fan_modes
@property
@override
def preset_mode(self) -> str | None:
@@ -202,7 +213,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
if preset_mode is not None:
return COMPIT_PRESET_MAP.get(CompitPresetMode(preset_mode))
compit_preset_mode = CompitPresetMode(preset_mode)
return COMPIT_PRESET_MAP.get(compit_preset_mode)
return None
@property
@@ -211,7 +223,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
"""Return the current fan mode."""
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
if fan_mode is not None:
return COMPIT_FANSPEED_MAP.get(CompitFanMode(fan_mode))
compit_fan_mode = CompitFanMode(fan_mode)
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
return None
@property
@@ -220,7 +233,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
"""Return the current HVAC mode."""
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
if hvac_mode is not None:
return COMPIT_MODE_MAP.get(CompitHVACMode(hvac_mode))
compit_hvac_mode = CompitHVACMode(hvac_mode)
return COMPIT_MODE_MAP.get(compit_hvac_mode)
return None
@override
@@ -575,6 +575,9 @@ DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
CompitParameter.VENTILATION_ALARM: DESCRIPTIONS[
CompitParameter.VENTILATION_ALARM
],
CompitParameter.VENTILATION_GEAR: DESCRIPTIONS[
CompitParameter.VENTILATION_GEAR
],
},
),
14: CompitDeviceDescription(
@@ -733,7 +733,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
# If already playing, or don't want to autoplay, no need to call Play
autoplay = extra.get("autoplay", True)
if self._device.transport_state is TransportState.PLAYING or not autoplay:
if self._device.transport_state == TransportState.PLAYING or not autoplay:
return
# Play it
@@ -766,7 +766,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
if not (play_mode := self._device.play_mode):
return None
if play_mode is PlayMode.VENDOR_DEFINED:
if play_mode == PlayMode.VENDOR_DEFINED:
return None
return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM)
@@ -802,10 +802,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
if not (play_mode := self._device.play_mode):
return None
if play_mode is PlayMode.VENDOR_DEFINED:
if play_mode == PlayMode.VENDOR_DEFINED:
return None
if play_mode is PlayMode.REPEAT_ONE:
if play_mode == PlayMode.REPEAT_ONE:
return RepeatMode.ONE
if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM):
+3 -3
View File
@@ -157,9 +157,9 @@ class ElevenLabsSTTEntity(SpeechToTextEntity):
raw_pcm_compatible = (
metadata.codec == AudioCodecs.PCM
and metadata.sample_rate is AudioSampleRates.SAMPLERATE_16000
and metadata.channel is AudioChannels.CHANNEL_MONO
and metadata.bit_rate is AudioBitRates.BITRATE_16
and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000
and metadata.channel == AudioChannels.CHANNEL_MONO
and metadata.bit_rate == AudioBitRates.BITRATE_16
)
if raw_pcm_compatible:
file_format = "pcm_s16le_16"
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.9"],
"requirements": ["pyenphase==3.0.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
+4 -1
View File
@@ -109,7 +109,10 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
state_float = state.state
if not math.isfinite(state_float):
return None
if self.device_class is SensorDeviceClass.TIMESTAMP:
if self.device_class in (
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
):
return dt_util.utc_from_timestamp(state_float)
return state_float
@@ -71,6 +71,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._async_abort_entries_match({CONF_WEBFSAPI_URL: self._webfsapi_url})
return await self._async_step_device_config_if_needed()
data_schema = self.add_suggested_values_to_schema(
+3 -3
View File
@@ -143,7 +143,7 @@ def _get_entity_descriptions(
local_sync = True
if (
search := data.get(CONF_SEARCH)
) or calendar_item.access_role is AccessRole.FREE_BUSY_READER:
) or calendar_item.access_role == AccessRole.FREE_BUSY_READER:
read_only = True
local_sync = False
entity_description = GoogleCalendarEntityDescription(
@@ -388,14 +388,14 @@ class GoogleCalendarEntity(
"""Return True if the event is visible and not declined."""
if any(
attendee.is_self and attendee.response_status is ResponseStatus.DECLINED
attendee.is_self and attendee.response_status == ResponseStatus.DECLINED
for attendee in event.attendees
):
return False
# Calendar enttiy may be limited to a specific event type
if (
self.entity_description.event_type is not None
and self.entity_description.event_type is not event.event_type
and self.entity_description.event_type != event.event_type
):
return False
# Default calendar entity omits the special types but includes all the others
@@ -754,7 +754,7 @@ async def async_prepare_files_for_prompt(
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
if uploaded_file.state is FileState.FAILED:
if uploaded_file.state == FileState.FAILED:
raise HomeAssistantError(
f"File `{uploaded_file.name}` processing"
" failed, reason:"
@@ -766,7 +766,7 @@ async def async_prepare_files_for_prompt(
tasks = [
asyncio.create_task(wait_for_file_processing(part))
for part in prompt_parts
if part.state is not FileState.ACTIVE
if part.state != FileState.ACTIVE
]
async with asyncio.timeout(TIMEOUT_MILLIS / 1000):
await asyncio.gather(*tasks)
+1 -23
View File
@@ -5,7 +5,6 @@ from functools import partial
import logging
import os
import struct
from typing import Any
from aiohasupervisor import SupervisorBadRequestError, SupervisorError
from aiohasupervisor.models import (
@@ -17,7 +16,7 @@ from aiohasupervisor.models import (
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken, User
from homeassistant.components import frontend, network
from homeassistant.components import frontend
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.onboarding import async_is_onboarded
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -31,7 +30,6 @@ from homeassistant.helpers import (
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
@@ -56,19 +54,14 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_HASSIO_HOST,
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
MAIN_COORDINATOR,
STATS_COORDINATOR,
UPDATE_KEY_NETWORK,
)
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
@@ -392,21 +385,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))
@callback
def _async_supervisor_event(event: dict[str, Any]) -> None:
"""Reload network adapters when Supervisor reports a network change."""
if (
event.get(ATTR_WS_EVENT) == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_NETWORK
):
entry.async_create_background_task(
hass, network.async_reload_adapters(hass), "hassio_reload_adapters"
)
entry.async_on_unload(
async_dispatcher_connect(hass, EVENT_SUPERVISOR_EVENT, _async_supervisor_event)
)
async def update_hass_api(refresh_token: RefreshToken) -> None:
"""Update Home Assistant API data on Hass.io."""
# hass.config.api is always set here: hassio depends on http, and the
-1
View File
@@ -91,7 +91,6 @@ EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_JOB = "job"
UPDATE_KEY_NETWORK = "network"
UPDATE_KEY_SUPERVISOR = "supervisor"
STARTUP_COMPLETE = "complete"
@@ -1,7 +1,6 @@
{
"domain": "hassio",
"name": "Home Assistant Supervisor",
"after_dependencies": ["network"],
"codeowners": ["@home-assistant/supervisor"],
"dependencies": ["http", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/hassio",
+2 -2
View File
@@ -261,7 +261,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
version = AwesomeVersion(self.latest_version)
if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN:
if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN:
return "https://github.com/home-assistant/operating-system/commits/dev"
return (
f"https://github.com/home-assistant/operating-system/releases/tag/{version}"
@@ -324,7 +324,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
version = AwesomeVersion(self.latest_version)
if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN:
if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN:
return "https://github.com/home-assistant/supervisor/commits/main"
return f"https://github.com/home-assistant/supervisor/releases/tag/{version}"
+1 -1
View File
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyhelty"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyhelty==0.2.0"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.98", "babel==2.18.0"]
"requirements": ["holidays==0.99", "babel==2.18.0"]
}
@@ -5,6 +5,7 @@ from homeassistant.const import Platform
DOMAIN = "hr_energy_qube"
PLATFORMS = (
Platform.BINARY_SENSOR,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
@@ -25,6 +25,7 @@ class QubeData:
state: QubeState
switches: dict[str, bool | None]
sg_ready_mode: str | None
class QubeCoordinator(DataUpdateCoordinator[QubeData]):
@@ -49,6 +50,7 @@ class QubeCoordinator(DataUpdateCoordinator[QubeData]):
try:
state = await self.client.get_all_data()
switches = await self.client.read_all_switches()
sg_ready_mode = await self.client.get_sg_ready_mode()
except (ConnectionError, TimeoutError, OSError) as exc:
raise UpdateFailed(
f"Error communicating with Qube heat pump: {exc}"
@@ -57,4 +59,4 @@ class QubeCoordinator(DataUpdateCoordinator[QubeData]):
if state is None:
raise UpdateFailed("No data received from Qube heat pump")
return QubeData(state=state, switches=switches)
return QubeData(state=state, switches=switches, sg_ready_mode=sg_ready_mode)
@@ -0,0 +1,67 @@
"""Select platform for Qube Heat Pump."""
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import QubeConfigEntry
from .const import DOMAIN
from .coordinator import QubeCoordinator
from .entity import QubeEntity
PARALLEL_UPDATES = 1
SG_READY_OPTIONS = ["off", "block", "plus", "max"]
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube select entities."""
coordinator = entry.runtime_data.coordinator
async_add_entities([QubeSGReadySelect(coordinator, entry)])
class QubeSGReadySelect(QubeEntity, SelectEntity):
"""Qube SG Ready mode select entity."""
_attr_options = SG_READY_OPTIONS
_attr_translation_key = "sg_ready_mode"
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}-sg_ready_mode"
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.sg_ready_mode is not None
@property
def current_option(self) -> str | None:
"""Return the current SG Ready mode."""
return self.coordinator.data.sg_ready_mode
async def async_select_option(self, option: str) -> None:
"""Set the SG Ready mode."""
try:
success = await self.coordinator.client.set_sg_ready_mode(option)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
)
await self.coordinator.async_request_refresh()
@@ -130,6 +130,17 @@
"name": "User pump"
}
},
"select": {
"sg_ready_mode": {
"name": "Smart grid ready mode",
"state": {
"block": "Block",
"max": "Max",
"off": "[%key:common::state::off%]",
"plus": "Plus"
}
}
},
"sensor": {
"compressor_speed": {
"name": "Compressor speed"
+2 -1
View File
@@ -57,10 +57,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
gps_accuracy_threshold,
entry,
)
await hass.async_add_executor_job(account.setup)
entry.runtime_data = account
await hass.async_add_executor_job(account.setup)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_photo_cache(hass, account)
+6 -6
View File
@@ -110,16 +110,12 @@ class IcloudAccount:
with_family=self._with_family,
)
if self.api.requires_2fa:
# Trigger a new log in to ensure the user enters the 2FA code again.
raise PyiCloudFailedLoginException("2FA Required") # noqa: TRY301
except PyiCloudFailedLoginException:
self.api = None
# Login failed which means credentials need to be updated.
# Login failed which means credentials/2fa need to be updated.
_LOGGER.error(
(
"Your password for '%s' is no longer working; Go to the "
"Your iCloud account for '%s' is no longer working; Go to the "
"Integrations menu and click on Configure on the discovered Apple "
"iCloud card to login again"
),
@@ -129,6 +125,10 @@ class IcloudAccount:
self._require_reauth()
return
if self.api.requires_2fa:
self._require_reauth()
return
try:
# Gets device owners infos
user_info = self.api.devices.user_info
+91 -45
View File
@@ -32,12 +32,13 @@ from .const import (
CONF_TRUSTED_DEVICE = "trusted_device"
CONF_VERIFICATION_CODE = "verification_code"
CONF_REQUEST_NEW_CODE = "request_new_code"
_LOGGER = logging.getLogger(__name__)
class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a iCloud config flow."""
"""Handle an iCloud config flow."""
VERSION = 1
@@ -89,44 +90,74 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders=self._description_placeholders,
)
async def _request_2fa_code(self, errors: dict[str, str]) -> dict[str, str]:
"""Request an Apple 2FA code."""
if TYPE_CHECKING:
assert self.api is not None
try:
result = await self.hass.async_add_executor_job(self.api.request_2fa_code)
except PyiCloudException as error:
_LOGGER.error("Failed to request iCloud 2FA verification code: %s", error)
errors["base"] = "send_verification_code"
return errors
except Exception:
_LOGGER.exception(
"Unexpected error requesting iCloud 2FA verification code"
)
errors["base"] = "send_verification_code"
return errors
if result is False:
_LOGGER.error("PyiCloud request_2fa_code returned False")
errors["base"] = "send_verification_code"
return errors
_LOGGER.debug("Requested iCloud 2FA verification code")
return errors
async def _validate_and_create_entry(self, user_input, step_id):
"""Check if config is valid and create entry if so."""
self._password = user_input[CONF_PASSWORD]
extra_inputs = user_input
# If an existing entry was found, meaning this is a password update attempt,
# use those to get config values that aren't changing
# use those to get config values that aren't changing.
if self._existing_entry_data:
extra_inputs = self._existing_entry_data
if user_input is not None and CONF_PASSWORD in user_input:
extra_inputs[CONF_PASSWORD] = user_input[CONF_PASSWORD]
self._username = extra_inputs[CONF_USERNAME]
self._password = extra_inputs.get(CONF_PASSWORD, "")
self._with_family = extra_inputs.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY)
self._max_interval = extra_inputs.get(CONF_MAX_INTERVAL, DEFAULT_MAX_INTERVAL)
self._gps_accuracy_threshold = extra_inputs.get(
CONF_GPS_ACCURACY_THRESHOLD, DEFAULT_GPS_ACCURACY_THRESHOLD
)
# Check if already configured
# Check if already configured.
if self.unique_id is None:
await self.async_set_unique_id(self._username)
self._abort_if_unique_id_configured()
try:
self.api = await self.hass.async_add_executor_job(
PyiCloudService,
self._username,
self._password,
Store(self.hass, STORAGE_VERSION, STORAGE_KEY).path,
True,
None,
self._with_family,
)
except PyiCloudFailedLoginException as error:
_LOGGER.error("Error logging into iCloud service: %s", error)
self.api = None
errors = {CONF_PASSWORD: "invalid_auth"}
return self._show_setup_form(user_input, errors, step_id)
if self.api is None:
try:
self.api = await self.hass.async_add_executor_job(
PyiCloudService,
self._username,
self._password,
Store(self.hass, STORAGE_VERSION, STORAGE_KEY).path,
True,
None,
self._with_family,
)
except PyiCloudFailedLoginException as error:
_LOGGER.error("Error logging into iCloud service: %s", error)
self.api = None
errors = {CONF_PASSWORD: "invalid_auth"}
return self._show_setup_form(user_input, errors, step_id)
if self.api.requires_2fa:
return await self.async_step_verification_code()
@@ -153,7 +184,7 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
}
# If this is a password update attempt, don't try and creating one
# If this is a password update attempt, don't try creating a new one.
if self.source == SOURCE_USER:
return self.async_create_entry(title=self._username, data=data)
@@ -184,19 +215,31 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Initialise re-authentication."""
# Store existing entry data so it can be used later and set unique ID
# so existing config entry can be updated
# so existing config entry can be updated.
await self.async_set_unique_id(self.context["unique_id"])
self._existing_entry_data = {**entry_data}
self._description_placeholders = {"username": entry_data[CONF_USERNAME]}
# Get the API from the existing entry runtime data
self.api = self._get_reauth_entry().runtime_data.api
# If the API is None, it means the existing entry was never successfully authenticated,
# so we need to show the setup form again to get the password.
if self.api is None:
return self._show_setup_form(step_id="reauth_confirm")
# If the API is not None, it means the existing entry was successfully authenticated before,
# so we can proceed to the reauth_confirm step to trigger 2FA challenge.
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Update password for a config entry that can't authenticate."""
if user_input is None:
return self._show_setup_form(step_id="reauth_confirm")
"""Initialise re-authentication confirmation.
Update password for a config entry that can't authenticate (if changed)
and trigger 2FA challenge if needed.
"""
return await self._validate_and_create_entry(user_input, "reauth_confirm")
async def async_step_trusted_device(
@@ -268,6 +311,16 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return await self._show_verification_code_form(errors)
if user_input[CONF_REQUEST_NEW_CODE]:
# If the user requested a new code, request it
errors = await self._request_2fa_code(errors)
return await self._show_verification_code_form(errors)
if user_input[CONF_VERIFICATION_CODE] == "":
# If the user didn't provide a code, show the form again with an error
errors["base"] = "validate_verification_code"
return await self.async_step_verification_code(errors=errors)
if TYPE_CHECKING:
assert self.api is not None
@@ -286,31 +339,19 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
):
raise PyiCloudException("The code you entered is not valid.") # noqa: TRY301
except PyiCloudException as error:
# Reset to the initial 2FA state to allow the user to retry
# Redisplay the verification form after a failed verification attempt.
# For 2FA, do not request a new Apple verification code on every bad
# user entry. The original code may still be valid, and repeatedly
# requesting new codes can invalidate prior codes or trigger rate limits.
_LOGGER.error("Failed to verify verification code: %s", error)
self._trusted_device = None
self._verification_code = None
errors["base"] = "validate_verification_code"
if self.api.requires_2fa:
try:
self.api = await self.hass.async_add_executor_job(
PyiCloudService,
self._username,
self._password,
Store(self.hass, STORAGE_VERSION, STORAGE_KEY).path,
True,
None,
self._with_family,
)
return await self.async_step_verification_code(None, errors)
except PyiCloudFailedLoginException as error_login:
_LOGGER.error("Error logging into iCloud service: %s", error_login)
self.api = None
errors = {CONF_PASSWORD: "invalid_auth"}
return self._show_setup_form(user_input, errors, "user")
else:
return await self.async_step_trusted_device(None, errors)
return await self.async_step_verification_code(errors=errors)
return await self.async_step_trusted_device(errors=errors)
return await self.async_step_user(
{
@@ -323,12 +364,17 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def _show_verification_code_form(
self, errors: dict[str, str] | None = None
self, errors: dict[str, str]
) -> ConfigFlowResult:
"""Show the verification_code form to the user."""
return self.async_show_form(
step_id="verification_code",
data_schema=vol.Schema({vol.Required(CONF_VERIFICATION_CODE): str}),
data_schema=vol.Schema(
{
vol.Optional(CONF_VERIFICATION_CODE): str,
vol.Optional(CONF_REQUEST_NEW_CODE, default=False): bool,
}
),
errors=errors,
)
+3 -2
View File
@@ -37,6 +37,7 @@
},
"verification_code": {
"data": {
"request_new_code": "Request a new verification code",
"verification_code": "Verification code"
},
"description": "Please enter the verification code you just received from Apple",
@@ -118,7 +119,7 @@
"name": "Message"
},
"number": {
"description": "The phone number to call in lost mode (must contain country code).",
"description": "The phone number to call in lost mode. Must contain country code.",
"name": "Number"
}
},
@@ -142,7 +143,7 @@
"description": "Asks for a state update of all devices linked to an Apple Account.",
"fields": {
"account": {
"description": "Your Apple Account username (email).",
"description": "Your Apple Account username/email.",
"name": "Account"
}
},
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["kiwiki"],
"quality_scale": "legacy",
"requirements": ["kiwiki-client==0.1.1"]
"requirements": ["kiwiki-client==0.1.2"]
}
+32 -7
View File
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from datetime import date, datetime, time, timedelta
import logging
import random
from typing import override
@@ -612,9 +612,19 @@ class ThinQEnergySensorEntityDescription(SensorEntityDescription):
start_date_fn: Callable[[datetime], datetime]
end_date_fn: Callable[[datetime], datetime]
update_interval: timedelta = timedelta(days=1)
reset_at_midnight: bool = False
ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
ThinQEnergySensorEntityDescription(
key="today",
translation_key="energy_usage_today",
usage_period=USAGE_DAILY,
start_date_fn=lambda today: today,
end_date_fn=lambda today: today,
update_interval=timedelta(hours=1),
reset_at_midnight=True,
),
ThinQEnergySensorEntityDescription(
key="yesterday",
translation_key="energy_usage_yesterday",
@@ -815,6 +825,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
entity_description: ThinQEnergySensorEntityDescription
_stop_update: Callable[[], None] | None = None
_last_fetch_date: date | None = None
@override
async def async_added_to_hass(self) -> None:
@@ -859,22 +870,36 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
next_update = local_now + self.entity_description.update_interval
if self.coordinator.update_energy_at_time_of_day is not None:
# calculate next_update time by combining tomorrow
# and update_energy_at_time_of_day
if (
self.coordinator.update_energy_at_time_of_day is not None
and self.entity_description.update_interval >= timedelta(days=1)
):
# For daily sensors: align to a consistent time of day to avoid
# clock drift and reduce the chance of multiple sensors fetching
# simultaneously across restarts.
next_update = datetime.combine(
(next_update).date(),
next_update.date(),
self.coordinator.update_energy_at_time_of_day,
next_update.tzinfo,
)
start_date = (self.entity_description.start_date_fn(local_now)).date()
end_date = (self.entity_description.end_date_fn(local_now)).date()
try:
self._attr_native_value = await self.coordinator.api.async_get_energy_usage(
energy_property=self.property_id,
period=self.entity_description.usage_period,
start_date=(self.entity_description.start_date_fn(local_now)).date(),
end_date=(self.entity_description.end_date_fn(local_now)).date(),
start_date=start_date,
end_date=end_date,
detail=False,
)
if (
self.entity_description.reset_at_midnight
and start_date != self._last_fetch_date
):
self._attr_last_reset = datetime.combine(
start_date, time.min, local_now.tzinfo
)
self._last_fetch_date = start_date
except ThinQAPIException as exc:
_LOGGER.warning(
"[%s:%s] Failed to fetch energy usage data. reason=%s",
@@ -769,6 +769,9 @@
"energy_usage_this_month": {
"name": "Energy this month"
},
"energy_usage_today": {
"name": "Energy today"
},
"energy_usage_yesterday": {
"name": "Energy yesterday"
},
+10 -1
View File
@@ -1,7 +1,7 @@
"""Litter-Robot entities for common data and methods."""
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate, Generic, TypeVar, override
from typing import Any, Concatenate, Generic, NoReturn, TypeVar, override
from pylitterbot import Pet, Robot
from pylitterbot.exceptions import LitterRobotException
@@ -38,6 +38,15 @@ def whisker_command[_WhiskerEntityT2: LitterRobotEntity, **_P](
return handler
def raise_update_failed(entity_id: str) -> NoReturn:
"""Raise when the robot rejected an update without an error response."""
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"entity_id": entity_id},
)
def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo:
"""Get device info for a robot or pet."""
if isinstance(whisker_entity, Robot):
@@ -105,6 +105,71 @@
"state": {
"on": "mdi:lock"
}
},
"sleep_mode_friday": {
"default": "mdi:sleep"
},
"sleep_mode_monday": {
"default": "mdi:sleep"
},
"sleep_mode_saturday": {
"default": "mdi:sleep"
},
"sleep_mode_sunday": {
"default": "mdi:sleep"
},
"sleep_mode_thursday": {
"default": "mdi:sleep"
},
"sleep_mode_tuesday": {
"default": "mdi:sleep"
},
"sleep_mode_wednesday": {
"default": "mdi:sleep"
}
},
"time": {
"sleep_mode_end_time_friday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_monday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_saturday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_sunday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_thursday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_tuesday": {
"default": "mdi:alarm"
},
"sleep_mode_end_time_wednesday": {
"default": "mdi:alarm"
},
"sleep_mode_start_time_friday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_monday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_saturday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_sunday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_thursday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_tuesday": {
"default": "mdi:sleep"
},
"sleep_mode_start_time_wednesday": {
"default": "mdi:sleep"
}
}
},
@@ -214,11 +214,74 @@
},
"panel_lockout": {
"name": "Panel lockout"
},
"sleep_mode_friday": {
"name": "Friday sleep mode"
},
"sleep_mode_monday": {
"name": "Monday sleep mode"
},
"sleep_mode_saturday": {
"name": "Saturday sleep mode"
},
"sleep_mode_sunday": {
"name": "Sunday sleep mode"
},
"sleep_mode_thursday": {
"name": "Thursday sleep mode"
},
"sleep_mode_tuesday": {
"name": "Tuesday sleep mode"
},
"sleep_mode_wednesday": {
"name": "Wednesday sleep mode"
}
},
"time": {
"sleep_mode_end_time_friday": {
"name": "Friday sleep mode end time"
},
"sleep_mode_end_time_monday": {
"name": "Monday sleep mode end time"
},
"sleep_mode_end_time_saturday": {
"name": "Saturday sleep mode end time"
},
"sleep_mode_end_time_sunday": {
"name": "Sunday sleep mode end time"
},
"sleep_mode_end_time_thursday": {
"name": "Thursday sleep mode end time"
},
"sleep_mode_end_time_tuesday": {
"name": "Tuesday sleep mode end time"
},
"sleep_mode_end_time_wednesday": {
"name": "Wednesday sleep mode end time"
},
"sleep_mode_start_time": {
"name": "[%key:component::litterrobot::entity::sensor::sleep_mode_start_time::name%]"
},
"sleep_mode_start_time_friday": {
"name": "Friday sleep mode start time"
},
"sleep_mode_start_time_monday": {
"name": "Monday sleep mode start time"
},
"sleep_mode_start_time_saturday": {
"name": "Saturday sleep mode start time"
},
"sleep_mode_start_time_sunday": {
"name": "Sunday sleep mode start time"
},
"sleep_mode_start_time_thursday": {
"name": "Thursday sleep mode start time"
},
"sleep_mode_start_time_tuesday": {
"name": "Tuesday sleep mode start time"
},
"sleep_mode_start_time_wednesday": {
"name": "Wednesday sleep mode start time"
}
},
"vacuum": {
@@ -239,6 +302,9 @@
},
"invalid_credentials": {
"message": "Invalid credentials. Please check your username and password, then try again"
},
"update_failed": {
"message": "Unable to update {entity_id}"
}
},
"services": {
+37 -4
View File
@@ -2,9 +2,11 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from functools import partial
from typing import Any, Generic, override
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot5, Robot
from pylitterbot.sleep_schedule import DayOfWeek
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@@ -12,11 +14,30 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
from .entity import (
LitterRobotEntity,
_WhiskerEntityT,
raise_update_failed,
whisker_command,
)
PARALLEL_UPDATES = 1
def _lr5_sleep_day_enabled(robot: LitterRobot5, *, day: DayOfWeek) -> bool:
"""Return whether a day's sleep schedule is enabled."""
if (schedule := robot.sleep_schedule) is None:
return False
return (entry := schedule.get_day(day)) is not None and entry.is_enabled
async def _lr5_set_sleep_day_enabled(
robot: LitterRobot5, value: bool, *, day: DayOfWeek
) -> bool:
"""Enable or disable a day's sleep schedule, preserving its times."""
return await robot.set_sleep_mode(value, day_of_week=day)
@dataclass(frozen=True, kw_only=True)
class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): # noqa: UP046
"""A class that describes robot switch entities."""
@@ -46,6 +67,16 @@ SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,
),
LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,),
LitterRobot5: tuple(
RobotSwitchEntityDescription[LitterRobot5](
key=f"sleep_mode_{day.name.lower()}",
translation_key=f"sleep_mode_{day.name.lower()}",
entity_registry_enabled_default=False,
set_fn=partial(_lr5_set_sleep_day_enabled, day=day),
value_fn=partial(_lr5_sleep_day_enabled, day=day),
)
for day in DayOfWeek
),
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
key="panel_lock_enabled",
@@ -102,10 +133,12 @@ class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.robot, True)
if not await self.entity_description.set_fn(self.robot, True):
raise_update_failed(self.entity_id)
@whisker_command
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.robot, False)
if not await self.entity_description.set_fn(self.robot, False):
raise_update_failed(self.entity_id)
+81 -17
View File
@@ -3,9 +3,11 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import datetime, time
from functools import partial
from typing import Any, Generic, override
from pylitterbot import LitterRobot3
from pylitterbot import LitterRobot3, LitterRobot5, Robot
from pylitterbot.sleep_schedule import DayOfWeek, SleepScheduleDay
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
@@ -14,7 +16,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
from .entity import (
LitterRobotEntity,
_WhiskerEntityT,
raise_update_failed,
whisker_command,
)
PARALLEL_UPDATES = 1
@@ -32,19 +39,75 @@ def _as_local_time(start: datetime | None) -> time | None:
return dt_util.as_local(start).time() if start else None
LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3](
key="sleep_mode_start_time",
translation_key="sleep_mode_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time),
set_fn=(
lambda robot, value: robot.set_sleep_mode(
robot.sleep_mode_enabled,
value.replace(tzinfo=dt_util.get_default_time_zone()),
def _lr5_schedule_day(robot: LitterRobot5, day: DayOfWeek) -> SleepScheduleDay | None:
"""Return the robot's sleep schedule entry for a day."""
if (schedule := robot.sleep_schedule) is None:
return None
return schedule.get_day(day)
def _lr5_schedule_time(
robot: LitterRobot5, *, day: DayOfWeek, wake: bool
) -> time | None:
"""Return a day's configured sleep start or wake time.
The schedule stores wall-clock times in the robot's own timezone, so the
value is reported as-is (unlike Litter-Robot 3, which stores a UTC
datetime that must be converted).
"""
if (entry := _lr5_schedule_day(robot, day)) is None:
return None
return entry.wake_time if wake else entry.sleep_time
async def _lr5_set_schedule_time(
robot: LitterRobot5, value: time, *, day: DayOfWeek, wake: bool
) -> bool:
"""Set a day's sleep start or wake time, preserving its enabled state.
The schedule is minute-granular; seconds are dropped.
"""
entry = _lr5_schedule_day(robot, day)
enabled = entry.is_enabled if entry else False
minutes = value.hour * 60 + value.minute
if wake:
return await robot.set_sleep_mode(enabled, wake_time=minutes, day_of_week=day)
return await robot.set_sleep_mode(enabled, minutes, day_of_week=day)
LITTER_ROBOT_5_SLEEP_TIMES: tuple[RobotTimeEntityDescription[LitterRobot5], ...] = (
tuple(
RobotTimeEntityDescription[LitterRobot5](
key=f"sleep_mode_{kind}_time_{day.name.lower()}",
translation_key=f"sleep_mode_{kind}_time_{day.name.lower()}",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
value_fn=partial(_lr5_schedule_time, day=day, wake=wake),
set_fn=partial(_lr5_set_schedule_time, day=day, wake=wake),
)
),
for day in DayOfWeek
for kind, wake in (("start", False), ("end", True))
)
)
ROBOT_TIME_MAP: dict[type[Robot], tuple[RobotTimeEntityDescription[Any], ...]] = {
LitterRobot3: (
RobotTimeEntityDescription[LitterRobot3](
key="sleep_mode_start_time",
translation_key="sleep_mode_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time),
set_fn=(
lambda robot, value: robot.set_sleep_mode(
robot.sleep_mode_enabled,
value.replace(tzinfo=dt_util.get_default_time_zone()),
)
),
),
),
LitterRobot5: LITTER_ROBOT_5_SLEEP_TIMES,
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -63,13 +126,13 @@ async def async_setup_entry(
known_robots.update(new_robots)
async_add_entities(
LitterRobotTimeEntity(
robot=robot,
coordinator=coordinator,
description=LITTER_ROBOT_3_SLEEP_START,
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
if isinstance(robot, LitterRobot3)
for robot_type, descriptions in ROBOT_TIME_MAP.items()
if isinstance(robot, robot_type)
for description in descriptions
)
_check_robots()
@@ -91,4 +154,5 @@ class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):
@override
async def async_set_value(self, value: time) -> None:
"""Update the current value."""
await self.entity_description.set_fn(self.robot, value)
if not await self.entity_description.set_fn(self.robot, value):
raise_update_failed(self.entity_id)
@@ -8,7 +8,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(
@@ -10,6 +10,9 @@
"off": "mdi:snowflake-off"
}
},
"holiday_mode": {
"default": "mdi:beach"
},
"overheat_protection": {
"default": "mdi:fire",
"state": {
@@ -33,6 +36,20 @@
"tank_water_temperature": {
"default": "mdi:water-thermometer"
}
},
"switch": {
"frost_protection": {
"default": "mdi:snowflake",
"state": {
"off": "mdi:snowflake-off"
}
},
"overheat_protection": {
"default": "mdi:fire",
"state": {
"off": "mdi:fire-off"
}
}
}
}
}
@@ -109,6 +109,14 @@
"tank_water_temperature": {
"name": "Tank water temperature"
}
},
"switch": {
"frost_protection": {
"name": "Frost protection"
},
"overheat_protection": {
"name": "Overheat protection"
}
}
},
"exceptions": {
@@ -0,0 +1,280 @@
"""Switch platform for MELCloud Home."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome
from aiomelcloudhome.exceptions import (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
)
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class ATASwitchEntityDescription(SwitchEntityDescription):
"""Class to hold MELCloud Home ATA switch description."""
available_fn: Callable[[ATAUnit], bool]
is_on_fn: Callable[[ATAUnit], bool | None]
turn_on_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]]
@dataclass(frozen=True, kw_only=True)
class ATWSwitchEntityDescription(SwitchEntityDescription):
"""Class to hold MELCloud Home ATW switch description."""
available_fn: Callable[[ATWUnit], bool]
is_on_fn: Callable[[ATWUnit], bool | None]
turn_on_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]]
ATA_SWITCHES: tuple[ATASwitchEntityDescription, ...] = (
ATASwitchEntityDescription(
key="frost_protection",
translation_key="frost_protection",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
available_fn=lambda unit: unit.frost_protection is not None,
is_on_fn=lambda unit: (
unit.frost_protection.enabled if unit.frost_protection else None
),
turn_on_fn=lambda client, unit: client.set_frost_protection(
enabled=True,
min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
ata_unit_ids=[unit.id],
),
turn_off_fn=lambda client, unit: client.set_frost_protection(
enabled=False,
min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
ata_unit_ids=[unit.id],
),
),
ATASwitchEntityDescription(
key="overheat_protection",
translation_key="overheat_protection",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
available_fn=lambda unit: unit.overheat_protection is not None,
is_on_fn=lambda unit: (
unit.overheat_protection.enabled if unit.overheat_protection else None
),
turn_on_fn=lambda client, unit: client.set_overheat_protection(
enabled=True,
min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
ata_unit_ids=[unit.id],
),
turn_off_fn=lambda client, unit: client.set_overheat_protection(
enabled=False,
min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
ata_unit_ids=[unit.id],
),
),
)
ATW_SWITCHES: tuple[ATWSwitchEntityDescription, ...] = (
ATWSwitchEntityDescription(
key="frost_protection",
translation_key="frost_protection",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
available_fn=lambda unit: unit.frost_protection is not None,
is_on_fn=lambda unit: (
unit.frost_protection.enabled if unit.frost_protection else None
),
turn_on_fn=lambda client, unit: client.set_frost_protection(
enabled=True,
min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
atw_unit_ids=[unit.id],
),
turn_off_fn=lambda client, unit: client.set_frost_protection(
enabled=False,
min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
atw_unit_ids=[unit.id],
),
),
ATWSwitchEntityDescription(
key="overheat_protection",
translation_key="overheat_protection",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
available_fn=lambda unit: unit.overheat_protection is not None,
is_on_fn=lambda unit: (
unit.overheat_protection.enabled if unit.overheat_protection else None
),
turn_on_fn=lambda client, unit: client.set_overheat_protection(
enabled=True,
min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
atw_unit_ids=[unit.id],
),
turn_off_fn=lambda client, unit: client.set_overheat_protection(
enabled=False,
min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
atw_unit_ids=[unit.id],
),
),
)
async def _perform_action(
coordinator: MelCloudHomeCoordinator,
coroutine: Coroutine[Any, Any, None],
) -> None:
"""Perform a MELCloud Home action with error handling and coordinator refresh."""
try:
await coroutine
except MelCloudHomeAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from err
except MelCloudHomeConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except MelCloudHomeTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect",
) from err
else:
await coordinator.async_request_refresh()
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home switches."""
coordinator = entry.runtime_data
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
async_add_entities(
ATASwitch(coordinator, entity_description, unit)
for entity_description in ATA_SWITCHES
for unit in units
)
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
async_add_entities(
ATWSwitch(coordinator, entity_description, unit)
for entity_description in ATW_SWITCHES
for unit in units
)
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
_async_add_new_ata_units(list(coordinator.ata_units.values()))
_async_add_new_atw_units(list(coordinator.atw_units.values()))
class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity):
"""Representation of a MELCloud Home ATA switch."""
entity_description: ATASwitchEntityDescription
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
entity_description: ATASwitchEntityDescription,
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self.entity_description = entity_description
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self.entity_description.available_fn(self.unit)
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return self.entity_description.is_on_fn(self.unit)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the protection."""
await _perform_action(
self.coordinator,
self.entity_description.turn_on_fn(self.coordinator.client, self.unit),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the protection."""
await _perform_action(
self.coordinator,
self.entity_description.turn_off_fn(self.coordinator.client, self.unit),
)
class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity):
"""Representation of a MELCloud Home ATW switch."""
entity_description: ATWSwitchEntityDescription
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
entity_description: ATWSwitchEntityDescription,
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self.entity_description = entity_description
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self.entity_description.available_fn(self.unit)
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return self.entity_description.is_on_fn(self.unit)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the protection."""
await _perform_action(
self.coordinator,
self.entity_description.turn_on_fn(self.coordinator.client, self.unit),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the protection."""
await _perform_action(
self.coordinator,
self.entity_description.turn_off_fn(self.coordinator.client, self.unit),
)
@@ -7,7 +7,6 @@ from pathlib import Path
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.util import package
@@ -18,7 +17,6 @@ from .const import (
LOOPBACK_TARGET_IP,
MDNS_TARGET_IP,
PUBLIC_TARGET_IP,
SIGNAL_NETWORK_ADAPTERS_CHANGED,
)
from .models import Adapter
from .network import Network, async_get_loaded_network, async_get_network
@@ -53,12 +51,6 @@ def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
return async_get_loaded_network(hass).adapters
async def async_reload_adapters(hass: HomeAssistant) -> None:
"""Reload the network adapters and notify listeners if they changed."""
if await async_get_loaded_network(hass).async_reload():
async_dispatcher_send(hass, SIGNAL_NETWORK_ADAPTERS_CHANGED)
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
) -> str:
@@ -19,8 +19,6 @@ MDNS_TARGET_IP: Final = "224.0.0.251"
PUBLIC_TARGET_IP: Final = "8.8.8.8"
IPV4_BROADCAST_ADDR: Final = "255.255.255.255"
SIGNAL_NETWORK_ADAPTERS_CHANGED: Final = "network_adapters_changed"
NETWORK_CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(
@@ -63,15 +63,6 @@ class Network:
self.adapters = await async_load_adapters()
await storage_load_task
async def async_reload(self) -> bool:
"""Reload adapters from the system, returning True if they changed."""
adapters = await async_load_adapters()
if adapters == self.adapters:
return False
self.adapters = adapters
self.async_configure()
return True
@callback
def async_configure(self) -> None:
"""Configure from storage."""
@@ -1,16 +1,23 @@
"""The NFAndroidTV integration."""
import logging
from notifications_android_tv.notifications import ConnectError, Notifications
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DATA_HASS_CONFIG, DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.NOTIFY]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type NFAndroidTVConfigEntry = ConfigEntry[Notifications]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -20,9 +27,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: NFAndroidTVConfigEntry) -> bool:
"""Set up NFAndroidTV from a config entry."""
try:
client = await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST])
except ConnectError as e:
_LOGGER.debug("Full exception:", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_connection_error",
translation_placeholders={CONF_NAME: entry.title},
) from e
entry.runtime_data = client
hass.async_create_task(
discovery.async_load_platform(
hass,
@@ -33,9 +52,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: NFAndroidTVConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,9 @@
{
"entity": {
"notify": {
"notify": {
"default": "mdi:television"
}
}
}
}
+48 -1
View File
@@ -14,13 +14,18 @@ from homeassistant.components.notify import (
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.const import ATTR_ICON, CONF_HOST
from homeassistant.const import ATTR_ICON, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import NFAndroidTVConfigEntry
from .const import (
ATTR_COLOR,
ATTR_DURATION,
@@ -48,6 +53,48 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NFAndroidTVConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the notify platform."""
async_add_entities([NFAndroidTVNotifyEntity(config_entry)])
class NFAndroidTVNotifyEntity(NotifyEntity):
"""Representation of a notify entity."""
_attr_supported_features = NotifyEntityFeature.TITLE
_attr_translation_key = "notify"
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: NFAndroidTVConfigEntry) -> None:
"""Initialize the entity."""
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=entry.title,
model="Notifications",
manufacturer="dream apps",
identifiers={(DOMAIN, entry.entry_id)},
)
self.entry = entry
self.client = entry.runtime_data
def send_message(self, message: str, title: str | None = None) -> None:
"""Send a message via notify.send_message action."""
try:
self.client.send(message=message, title=title)
except ConnectError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_connection_error",
translation_placeholders={CONF_NAME: self.entry.title},
) from e
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
@@ -39,6 +39,12 @@
},
"invalid_notification_image": {
"message": "Invalid image data provided. Got {type}"
},
"notify_connection_error": {
"message": "Failed to send notification to {name} due to a connection error"
},
"setup_connection_error": {
"message": "Failed to connect to {name}"
}
}
}
+16 -2
View File
@@ -27,6 +27,7 @@ from .const import (
DEFAULT_MAX_HISTORY,
DEFAULT_NUM_CTX,
DOMAIN,
KEEP_ALIVE_FOREVER,
)
from .models import MessageHistory, MessageRole
@@ -243,8 +244,21 @@ class OllamaBaseLLMEntity(Entity):
messages=list(message_history.messages),
tools=tools,
stream=True,
# keep_alive requires specifying unit. In this case, seconds
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
# keep_alive: -1 is a special sentinel meaning "keep loaded
# forever" and must be passed as the integer -1, not as a
# duration string ("-1s" would be treated as an invalid
# negative duration by the Ollama server). All other values
# are expressed as a duration string with a seconds suffix.
keep_alive=(
keep_alive_seconds
if (
keep_alive_seconds := int(
settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)
)
)
== KEEP_ALIVE_FOREVER
else f"{keep_alive_seconds}s"
),
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
think=settings.get(CONF_THINK),
format=output_format,
@@ -11,7 +11,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NUMBER,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
@@ -0,0 +1,97 @@
"""Support for OpenEVSE button entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from openevsehttp.__main__ import OpenEVSE
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
from .helpers import openevse_exception_handler
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OpenEVSEButtonDescription(ButtonEntityDescription):
"""Describes an OpenEVSE button entity."""
press_fn: Callable[[OpenEVSE], Awaitable[Any]]
BUTTON_TYPES: tuple[OpenEVSEButtonDescription, ...] = (
OpenEVSEButtonDescription(
key="restart_wifi",
translation_key="restart_wifi",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda ev: ev.restart_wifi(),
),
OpenEVSEButtonDescription(
key="restart_evse",
translation_key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda ev: ev.restart_evse(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenEVSE buttons based on config entry."""
coordinator = entry.runtime_data
identifier = entry.unique_id or entry.entry_id
async_add_entities(
OpenEVSEButton(coordinator, description, identifier, entry.unique_id)
for description in BUTTON_TYPES
)
class OpenEVSEButton(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], ButtonEntity):
"""Implementation of an OpenEVSE button."""
_attr_has_entity_name = True
entity_description: OpenEVSEButtonDescription
def __init__(
self,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSEButtonDescription,
identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)},
manufacturer="OpenEVSE",
)
if unique_id:
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, unique_id)
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
async def async_press(self) -> None:
"""Press the button."""
with openevse_exception_handler(0.0):
await self.entity_description.press_fn(self.coordinator.charger)
@@ -62,6 +62,14 @@
"name": "Vehicle connected"
}
},
"button": {
"restart": {
"name": "Restart"
},
"restart_wifi": {
"name": "Restart Wi-Fi"
}
},
"number": {
"charge_rate": {
"name": "Charge rate"
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
"quality_scale": "legacy",
"quality_scale": "bronze",
"requirements": ["opensensemap-api==0.4.1"]
}
@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration does not register any actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not register any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities do not subscribe to events; they read from a
DataUpdateCoordinator.
entity-unique-id: done
has-entity-name:
status: exempt
comment: |
Sensor entities set has_entity_name. The legacy air_quality entity does
not, but it is being deprecated and removed in a follow-up.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration does not register any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: The integration has no options to configure.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: The openSenseMap API requires no authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: openSenseMap is a cloud service and cannot be discovered.
discovery:
status: exempt
comment: openSenseMap stations cannot be discovered on the network.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Each config entry represents a single, fixed station.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The integration only provides primary entities that should be enabled.
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
comment: Entities use their device class icons; no custom icons are needed.
reconfiguration-flow:
status: exempt
comment: |
The station ID is the unique identifier of the config entry; a different
station is a separate entry, so there is nothing to reconfigure.
repair-issues: done
stale-devices:
status: exempt
comment: Each config entry represents a single, fixed station.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
+3 -1
View File
@@ -10,7 +10,7 @@ from pyoverkiz.auth.credentials import (
RexelTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClient, OverkizClientSettings
from pyoverkiz.const import REXEL_OAUTH_CLIENT_ID
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
from pyoverkiz.exceptions import (
@@ -317,6 +317,7 @@ def create_local_client(
credentials=LocalTokenCredentials(token),
session=session,
verify_ssl=verify_ssl,
settings=OverkizClientSettings(default_rts_command_duration=0),
)
@@ -332,6 +333,7 @@ def create_cloud_client(
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
settings=OverkizClientSettings(default_rts_command_duration=0),
)
@@ -14,6 +14,7 @@ from pyoverkiz.client import GatewayCandidate, OverkizClient
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
ApplicationNotAllowedError,
BadCredentialsError,
CozyTouchBadCredentialsError,
MaintenanceError,
@@ -193,6 +194,8 @@ class OverkizConfigFlow(
await self.async_validate_input(user_input)
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except ApplicationNotAllowedError:
errors["base"] = "application_not_allowed"
except (BadCredentialsError, NotAuthenticatedError) as exception:
# If authentication with CozyTouch auth server is
# valid, but token is invalid for Overkiz API
+8 -8
View File
@@ -118,9 +118,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
stop_command=OverkizCommand.STOP,
# position (1-127), execution duration (0-15, optional)
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(_TILT_STEP_SIZE, 0),
open_tilt_command_args=(_TILT_STEP_SIZE,),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(_TILT_STEP_SIZE, 0),
close_tilt_command_args=(_TILT_STEP_SIZE,),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands
@@ -134,9 +134,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
stop_command=OverkizCommand.STOP,
# position (1-127), execution duration (0-15, optional)
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(_TILT_STEP_SIZE, 0),
open_tilt_command_args=(_TILT_STEP_SIZE,),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(_TILT_STEP_SIZE, 0),
close_tilt_command_args=(_TILT_STEP_SIZE,),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands
@@ -150,9 +150,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
stop_command=OverkizCommand.STOP,
# position (1-127), execution duration (0-15, optional)
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(_TILT_STEP_SIZE, 0),
open_tilt_command_args=(_TILT_STEP_SIZE,),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(_TILT_STEP_SIZE, 0),
close_tilt_command_args=(_TILT_STEP_SIZE,),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:SheerBlindRTSComponent)
@@ -165,9 +165,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
stop_command=OverkizCommand.STOP,
# position (1-127), execution duration (0-15, optional)
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(_TILT_STEP_SIZE, 0),
open_tilt_command_args=(_TILT_STEP_SIZE,),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(_TILT_STEP_SIZE, 0),
close_tilt_command_args=(_TILT_STEP_SIZE,),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since BioclimaticPergola uses core:SlatsOpenClosedState
+1 -20
View File
@@ -2,7 +2,7 @@
from typing import Any
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.enums import OverkizCommand
from pyoverkiz.exceptions import BaseOverkizError
from pyoverkiz.models import Action, Command, Device, StateDefinition
@@ -10,18 +10,6 @@ from homeassistant.exceptions import HomeAssistantError
from .coordinator import OverkizDataUpdateCoordinator
# Commands that don't support setting
# the delay to another value
COMMANDS_WITHOUT_DELAY = [
OverkizCommand.IDENTIFY,
OverkizCommand.OFF,
OverkizCommand.ON,
OverkizCommand.ON_WITH_TIMER,
OverkizCommand.TEST,
OverkizCommand.TILT_POSITIVE,
OverkizCommand.TILT_NEGATIVE,
]
class OverkizExecutor:
"""Representation of an Overkiz device with execution handler."""
@@ -61,13 +49,6 @@ class OverkizExecutor:
commands are executed, it will be refreshed only once.
"""
parameters = [arg for arg in args if arg is not None]
# Set the execution duration to 0 seconds for RTS devices on supported commands
# Default execution duration is 30 seconds and will block consecutive commands
if (
self.device.identifier.protocol == Protocol.RTS
and command_name not in COMMANDS_WITHOUT_DELAY
):
parameters.append(0)
try:
exec_id = await self.coordinator.client.execute_action_group(
@@ -18,6 +18,7 @@
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"error": {
"application_not_allowed": "Your setup cannot be accessed through this application.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"certificate_verify_failed": "Cannot connect to host, certificate verify failed.",
"developer_mode_disabled": "Developer Mode disabled. Activate the Developer Mode of your Somfy TaHoma box first.",
+3 -7
View File
@@ -5,10 +5,9 @@ from typing import cast
from python_picnic_api2 import PicnicAPI
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_AMOUNT,
@@ -50,12 +49,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def get_api_client(hass: HomeAssistant, config_entry_id: str) -> PicnicAPI:
"""Get the right Picnic API client based on the config entry id."""
entry: PicnicConfigEntry | None = hass.config_entries.async_get_entry(
config_entry_id
entry: PicnicConfigEntry = service.async_get_config_entry(
hass, DOMAIN, config_entry_id
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise ValueError(f"Config entry with id {config_entry_id} not found!")
return entry.runtime_data.picnic_api_client
+1 -2
View File
@@ -10,7 +10,6 @@ from homeassistant.components.calendar import (
CalendarEvent,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -121,7 +120,7 @@ class RachioCalendarEntity(
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
if not self.coordinator.data:
raise HomeAssistantError("No events scheduled")
return []
schedule = self.coordinator.data
event_list: list[CalendarEvent] = []
@@ -59,7 +59,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN):
meter_info = await raven_device.get_meter_info(meter=meter)
if meter_info and (
meter_info.meter_type is None
or meter_info.meter_type is MeterType.ELECTRIC
or meter_info.meter_type == MeterType.ELECTRIC
):
self._meter_macs.add(meter.hex())
self._dev_path = dev_path
@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.50",
"SQLAlchemy==2.0.51",
"fnv-hash-fast==2.0.3",
"psutil-home-assistant==0.0.1"
]
+1 -1
View File
@@ -705,7 +705,7 @@ class ReolinkHost:
self._api.host,
sub_type,
)
if sub_type is SubType.push:
if sub_type == SubType.push:
await self.subscribe()
return
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.1"]
"requirements": ["reolink-aio==0.21.2"]
}
+87 -148
View File
@@ -1,10 +1,8 @@
"""The Roborock component."""
import asyncio
from collections.abc import Coroutine
from datetime import timedelta
import logging
from typing import Any
from roborock import (
RoborockException,
@@ -19,10 +17,11 @@ from roborock.map.map_parser import MapParserConfig
from roborock.mqtt.session import MqttSessionUnauthorized
from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -75,6 +74,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
base_url=entry.data[CONF_BASE_URL],
)
cache = CacheStore(hass, entry.entry_id)
entry.runtime_data = RoborockCoordinators()
@callback
def handle_device_ready(device: RoborockDevice) -> None:
"""Handle a device becoming ready."""
entry.async_create_background_task(
hass,
async_setup_device(hass, entry, device),
name=f"roborock_device_setup_{device.duid}",
)
try:
device_manager = await create_device_manager(
user_params,
@@ -91,6 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
show_walls=entry.options.get(CONF_SHOW_WALLS, True),
map_scale=MAP_SCALE,
),
ready_callback=handle_device_ready,
mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass),
prefer_cache=False,
)
@@ -132,76 +144,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
devices = await device_manager.get_devices()
_LOGGER.debug("Device manager found %d devices", len(devices))
# Register all discovered devices in the device registry so we can
# check the disabled state before creating coordinators.
device_registry = dr.async_get(hass)
for device in devices:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**get_device_info(device),
)
enabled_devices = []
disabled_devices = []
for device in devices:
if _is_device_disabled(device_registry, device):
disabled_devices.append(device)
else:
enabled_devices.append(device)
_LOGGER.debug("%d of %d devices are enabled", len(enabled_devices), len(devices))
# Close connections for disabled devices to prevent their background
# reconnect loops from triggering MQTT session restarts that would
# disrupt coordinator setup for the enabled devices.
if disabled_devices:
close_results = await asyncio.gather(
*[device.close() for device in disabled_devices],
return_exceptions=True,
)
for device, close_result in zip(disabled_devices, close_results, strict=True):
if isinstance(close_result, Exception):
_LOGGER.debug(
"Failed to close disabled Roborock device %s: %s",
device.duid,
close_result,
)
coordinators = await asyncio.gather(
*build_setup_functions(hass, entry, enabled_devices, user_data),
return_exceptions=True,
)
v1_coords = [
coord
for coord in coordinators
if isinstance(coord, RoborockDataUpdateCoordinator)
]
a01_coords = [
coord
for coord in coordinators
if isinstance(coord, RoborockDataUpdateCoordinatorA01)
]
b01_q7_coords = [
coord
for coord in coordinators
if isinstance(coord, RoborockB01Q7UpdateCoordinator)
]
b01_q10_coords = [
coord
for coord in coordinators
if isinstance(coord, RoborockB01Q10UpdateCoordinator)
]
if (
len(v1_coords) + len(a01_coords) + len(b01_q7_coords) + len(b01_q10_coords) == 0
and enabled_devices
):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="no_coordinators",
)
entry.runtime_data = RoborockCoordinators(
v1_coords, a01_coords, b01_q7_coords, b01_q10_coords
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_remove_stale_devices(hass, entry, devices)
@@ -223,6 +165,7 @@ def _remove_stale_devices(
entry: RoborockConfigEntry,
devices: list[RoborockDevice],
) -> None:
"""Remove devices from the registry that are no longer in the account."""
device_map: dict[str, RoborockDevice] = {device.duid: device for device in devices}
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
@@ -237,7 +180,7 @@ def _remove_stale_devices(
if any(device_duid in device_map for device_duid in device_duids):
continue
_LOGGER.info(
"Removing device: %s because it is no longer exists in your account",
"Removing device: %s because it no longer exists in your account",
device.name,
)
device_registry.async_update_device(
@@ -268,87 +211,83 @@ async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -
return True
def build_setup_functions(
async def async_setup_device(
hass: HomeAssistant,
entry: RoborockConfigEntry,
devices: list[RoborockDevice],
user_data: UserData,
) -> list[
Coroutine[
Any,
Any,
device: RoborockDevice,
) -> None:
"""Set up a single Roborock device and its coordinator."""
_LOGGER.debug("Device %s is ready, setting it up", device.duid)
if device.duid in entry.runtime_data:
return
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**get_device_info(device),
)
if _is_device_disabled(device_registry, device):
_LOGGER.debug("Device %s is disabled, skipping setup", device.duid)
try:
await device.close()
except RoborockException as err:
_LOGGER.warning("Failed to close device %s: %s", device.duid, err)
return
_LOGGER.debug("Creating device %s: %s", device.name, device)
coordinator: (
RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockDataUpdateCoordinatorB01
| RoborockB01Q10UpdateCoordinator
| None,
]
]:
"""Create a list of setup functions that can later be called asynchronously."""
coordinators: list[
RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockDataUpdateCoordinatorB01
| RoborockB01Q10UpdateCoordinator
] = []
for device in devices:
_LOGGER.debug("Creating device %s: %s", device.name, device)
if device.v1_properties is not None:
coordinators.append(
RoborockDataUpdateCoordinator(hass, entry, device, device.v1_properties)
)
elif device.dyad is not None:
coordinators.append(
RoborockWetDryVacUpdateCoordinator(hass, entry, device, device.dyad)
)
elif device.zeo is not None:
coordinators.append(
RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo)
)
elif device.b01_q7_properties is not None:
coordinators.append(
RoborockB01Q7UpdateCoordinator(
hass, entry, device, device.b01_q7_properties
)
)
elif device.b01_q10_properties is not None:
coordinators.append(
RoborockB01Q10UpdateCoordinator(
hass, entry, device, device.b01_q10_properties
)
)
else:
_LOGGER.warning(
"Not adding device %s because its protocol version"
" %s or category %s is not supported",
device.duid,
device.device_info.pv,
device.product.category.name,
)
return [setup_coordinator(coordinator) for coordinator in coordinators]
async def setup_coordinator(
coordinator: RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockDataUpdateCoordinatorB01
| RoborockB01Q10UpdateCoordinator,
) -> (
RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockDataUpdateCoordinatorB01
| RoborockB01Q10UpdateCoordinator
| None
):
"""Set up a single coordinator."""
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
await coordinator.async_shutdown()
raise
| None
) = None
if device.v1_properties is not None:
coordinator = RoborockDataUpdateCoordinator(
hass, entry, device, device.v1_properties
)
elif device.dyad is not None:
coordinator = RoborockWetDryVacUpdateCoordinator(
hass, entry, device, device.dyad
)
elif device.zeo is not None:
coordinator = RoborockWashingMachineUpdateCoordinator(
hass, entry, device, device.zeo
)
elif device.b01_q7_properties is not None:
coordinator = RoborockB01Q7UpdateCoordinator(
hass, entry, device, device.b01_q7_properties
)
elif device.b01_q10_properties is not None:
coordinator = RoborockB01Q10UpdateCoordinator(
hass, entry, device, device.b01_q10_properties
)
else:
return coordinator
_LOGGER.warning(
"Not adding entities for device %s (%s/%s) because its protocol"
" version %s or category %s is not supported",
device.duid,
device.product.name,
device.product.model,
device.device_info.pv,
device.product.category.name,
)
try:
await device.close()
except RoborockException as err:
_LOGGER.warning("Failed to close device %s: %s", device.duid, err)
return
entry.runtime_data.add(coordinator)
async_dispatcher_send(
hass,
f"roborock_coordinator_added_{entry.entry_id}",
coordinator,
)
entry.async_create_background_task(
hass,
coordinator.async_refresh(),
name=f"roborock_coordinator_refresh_{coordinator.duid}",
)
async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
@@ -15,12 +15,14 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
@@ -168,26 +170,38 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock vacuum binary sensors."""
entities: list[BinarySensorEntity] = [
RoborockBinarySensorEntity(
coordinator,
description,
coordinators = config_entry.runtime_data
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
entities: list[BinarySensorEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockBinarySensorEntity(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
)
elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator):
entities.extend(
RoborockBinarySensorEntityA01(coordinator, description)
for description in ZEO_BINARY_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
for coordinator in config_entry.runtime_data.v1
for description in BINARY_SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
]
entities.extend(
RoborockBinarySensorEntityA01(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_BINARY_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(entities)
class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity):
+61 -44
View File
@@ -1,9 +1,7 @@
"""Support for Roborock button."""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import itertools
import logging
from typing import Any, override
@@ -13,14 +11,16 @@ from roborock.roborock_message import RoborockZeoProtocol
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
@@ -140,51 +140,68 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock button platform."""
routines_lists = await asyncio.gather(
*[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1],
)
async_add_entities(
itertools.chain(
(
RoborockButtonEntity(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.v1
if isinstance(coordinator, RoborockDataUpdateCoordinator)
coordinators = config_entry.runtime_data
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
entities: list[ButtonEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockButtonEntity(coordinator, description)
for description in CONSUMABLE_BUTTON_DESCRIPTIONS
if description.is_supported(coordinator)
),
(
RoborockRoutineButtonEntity(
coordinator,
ButtonEntityDescription(
key=str(routine.id),
name=routine.name,
),
)
for coordinator, routines in zip(
config_entry.runtime_data.v1, routines_lists, strict=True
)
for routine in routines
),
(
RoborockButtonEntityA01(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
)
async def async_add_routine_buttons() -> None:
try:
routines = await coordinator.get_routines()
except HomeAssistantError as err:
_LOGGER.error(
"Failed to get routines for %s: %s",
coordinator.device.name,
err,
)
return
routine_entities = [
RoborockRoutineButtonEntity(
coordinator,
ButtonEntityDescription(
key=str(routine.id),
name=routine.name,
),
)
for routine in routines
]
async_add_entities(routine_entities)
config_entry.async_create_background_task(
hass,
async_add_routine_buttons(),
f"roborock_setup_routines_{coordinator.duid}",
)
elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator):
entities.extend(
RoborockButtonEntityA01(coordinator, description)
for description in ZEO_BUTTON_DESCRIPTIONS
),
(
RoborockQ10EmptyDustbinButtonEntity(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.b01_q10
)
elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
entities.extend(
RoborockQ10EmptyDustbinButtonEntity(coordinator, description)
for description in Q10_BUTTON_DESCRIPTIONS
),
)
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
@@ -1,7 +1,6 @@
"""Roborock Coordinator."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any, override
@@ -58,14 +57,38 @@ MIN_UNAVAILABLE_DURATION = timedelta(minutes=2)
_LOGGER = logging.getLogger(__name__)
@dataclass
class RoborockCoordinators:
"""Roborock coordinators type."""
v1: list[RoborockDataUpdateCoordinator]
a01: list[RoborockDataUpdateCoordinatorA01]
b01_q7: list[RoborockB01Q7UpdateCoordinator]
b01_q10: list[RoborockB01Q10UpdateCoordinator]
def __init__(
self,
) -> None:
"""Initialize."""
self._coordinators: dict[
str,
RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockB01Q7UpdateCoordinator
| RoborockB01Q10UpdateCoordinator,
] = {}
def add(
self,
coordinator: RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01
| RoborockB01Q7UpdateCoordinator
| RoborockB01Q10UpdateCoordinator,
) -> None:
"""Add a coordinator."""
self._coordinators[coordinator.duid] = coordinator
def __contains__(self, duid: str) -> bool:
"""Check if a coordinator exists by DUID."""
return duid in self._coordinators
def keys(self) -> list[str]:
"""Return DUIDs of all registered coordinators."""
return list(self._coordinators.keys())
def values(
self,
@@ -78,6 +101,42 @@ class RoborockCoordinators:
"""Return all coordinators."""
return self.v1 + self.a01 + self.b01_q7 + self.b01_q10
@property
def v1(self) -> list[RoborockDataUpdateCoordinator]:
"""Return V1 coordinators."""
return [
coord
for coord in self._coordinators.values()
if isinstance(coord, RoborockDataUpdateCoordinator)
]
@property
def a01(self) -> list[RoborockDataUpdateCoordinatorA01]:
"""Return A01 coordinators."""
return [
coord
for coord in self._coordinators.values()
if isinstance(coord, RoborockDataUpdateCoordinatorA01)
]
@property
def b01_q7(self) -> list[RoborockB01Q7UpdateCoordinator]:
"""Return Q7 coordinators."""
return [
coord
for coord in self._coordinators.values()
if isinstance(coord, RoborockB01Q7UpdateCoordinator)
]
@property
def b01_q10(self) -> list[RoborockB01Q10UpdateCoordinator]:
"""Return Q10 coordinators."""
return [
coord
for coord in self._coordinators.values()
if isinstance(coord, RoborockB01Q10UpdateCoordinator)
]
type RoborockConfigEntry = ConfigEntry[RoborockCoordinators]
@@ -110,13 +169,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)}
self.last_update_state: str | None = None
# Keep track of last attempt to refresh maps/rooms to know when to try again.
self._last_home_update_attempt: datetime
self._last_home_update_attempt = dt_util.utcnow()
self.last_home_update: datetime | None = None
# Tracks the last successful update to control when we report failure
# to the base class. This is reset on successful data update.
self._last_update_success_time: datetime | None = None
self._has_connected_locally: bool = False
self._unsubs: list[Callable[[], None]] = []
self._setup_completed = False
@cached_property
def dock_device_info(self) -> DeviceInfo:
@@ -142,7 +202,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
try:
await self.properties_api.status.refresh()
except RoborockException as err:
_LOGGER.debug("Failed to update data during setup: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_data_fail",
@@ -239,7 +298,11 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
@override
async def _async_update_data(self) -> DeviceState | None:
"""Update data via library."""
await self._verify_api()
if not self._setup_completed:
await self._async_setup()
self._setup_completed = True
else:
await self._verify_api()
try:
# Update device props and standard api information
await self._update_device_prop()
@@ -668,3 +731,11 @@ class RoborockB01Q10UpdateCoordinator(DataUpdateCoordinator[None]):
def device(self) -> RoborockDevice:
"""Get the RoborockDevice."""
return self._device
type RoborockCoordinatorType = (
RoborockDataUpdateCoordinator
| RoborockDataUpdateCoordinatorA01[Any]
| RoborockB01Q7UpdateCoordinator
| RoborockB01Q10UpdateCoordinator
)
+32 -9
View File
@@ -12,9 +12,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -28,20 +33,38 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock image platform."""
coordinators = config_entry.runtime_data
async_add_entities(
(
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
if not isinstance(coordinator, RoborockDataUpdateCoordinator):
return
entities = [
RoborockMap(
config_entry,
coord,
coord.properties_api.home,
coordinator,
coordinator.properties_api.home,
map_info.map_flag,
map_info.name,
)
for coord in config_entry.runtime_data.v1
if coord.properties_api.home is not None
for map_info in (coord.properties_api.home.home_map_info or {}).values()
),
for map_info in (
coordinator.properties_api.home.home_map_info or {}
).values()
]
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
+28 -5
View File
@@ -10,12 +10,17 @@ from roborock.exceptions import RoborockException
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
from .entity import RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -58,18 +63,36 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock number platform."""
async_add_entities(
[
coordinators = config_entry.runtime_data
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
if not isinstance(coordinator, RoborockDataUpdateCoordinator):
return
entities = [
RoborockNumberEntity(
f"{description.key}_{coordinator.duid_slug}",
coordinator=coordinator,
entity_description=description,
trait=trait,
)
for coordinator in config_entry.runtime_data.v1
for description in NUMBER_DESCRIPTIONS
if (trait := description.trait(coordinator.properties_api)) is not None
]
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
+54 -32
View File
@@ -32,8 +32,9 @@ from roborock.roborock_typing import RoborockCommand
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MAP_SLEEP
@@ -41,8 +42,10 @@ from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
)
from .entity import (
RoborockCoordinatedEntityA01,
@@ -250,40 +253,59 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock select platform."""
coordinators = config_entry.runtime_data
async_add_entities(
RoborockSelectEntity(coordinator, description, options)
for coordinator in config_entry.runtime_data.v1
for description in SELECT_DESCRIPTIONS
if (
(options := description.options_lambda(coordinator.properties_api))
is not None
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
entities: list[SelectEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockSelectEntity(coordinator, description, options)
for description in SELECT_DESCRIPTIONS
if (options := description.options_lambda(coordinator.properties_api))
is not None
)
if (
coordinator.properties_api.home is not None
and coordinator.properties_api.maps is not None
):
entities.append(
RoborockCurrentMapSelectEntity(
f"selected_map_{coordinator.duid_slug}",
coordinator,
coordinator.properties_api.home,
coordinator.properties_api.maps,
)
)
elif isinstance(coordinator, RoborockB01Q7UpdateCoordinator):
entities.extend(
RoborockB01SelectEntity(coordinator, description, options)
for description in B01_SELECT_DESCRIPTIONS
if (options := description.options_lambda(coordinator.api)) is not None
)
elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator):
entities.extend(
RoborockSelectEntityA01(coordinator, description)
for description in A01_SELECT_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
entities.append(RoborockQ10CleanModeSelectEntity(coordinator))
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
async_add_entities(
RoborockCurrentMapSelectEntity(
f"selected_map_{coordinator.duid_slug}", coordinator, home_trait, map_trait
)
for coordinator in config_entry.runtime_data.v1
if (home_trait := coordinator.properties_api.home) is not None
if (map_trait := coordinator.properties_api.maps) is not None
)
async_add_entities(
RoborockB01SelectEntity(coordinator, description, options)
for coordinator in config_entry.runtime_data.b01_q7
for description in B01_SELECT_DESCRIPTIONS
if (options := description.options_lambda(coordinator.api)) is not None
)
async_add_entities(
RoborockSelectEntityA01(coordinator, description)
for coordinator in config_entry.runtime_data.a01
for description in A01_SELECT_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(
RoborockQ10CleanModeSelectEntity(coordinator)
for coordinator in config_entry.runtime_data.b01_q10
)
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity):
+49 -41
View File
@@ -30,7 +30,8 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -38,6 +39,7 @@ from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
@@ -539,48 +541,54 @@ async def async_setup_entry(
"""Set up the Roborock vacuum sensors."""
coordinators = config_entry.runtime_data
entities: list[RoborockEntity] = [
RoborockSensorEntity(
coordinator,
description,
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
entities: list[RoborockEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
)
entities.append(RoborockCurrentRoom(coordinator))
elif isinstance(coordinator, RoborockWetDryVacUpdateCoordinator):
entities.extend(
RoborockSensorEntityA01(coordinator, description)
for description in DYAD_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator):
entities.extend(
RoborockSensorEntityA01(coordinator, description)
for description in ZEO_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
elif isinstance(coordinator, RoborockB01Q7UpdateCoordinator):
entities.extend(
RoborockSensorEntityB01Q7(coordinator, description)
for description in Q7_B01_SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.data) is not None
)
elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
entities.extend(
RoborockSensorEntityB01Q10(coordinator, description)
for description in Q10_B01_SENSOR_DESCRIPTIONS
)
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
for coordinator in coordinators.v1
for description in SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
]
entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1)
entities.extend(
RoborockSensorEntityA01(
coordinator,
description,
)
for coordinator in coordinators.a01
if isinstance(coordinator, RoborockWetDryVacUpdateCoordinator)
for description in DYAD_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
entities.extend(
RoborockSensorEntityA01(
coordinator,
description,
)
for coordinator in coordinators.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
entities.extend(
RoborockSensorEntityB01Q7(coordinator, description)
for coordinator in coordinators.b01_q7
for description in Q7_B01_SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.data) is not None
)
entities.extend(
RoborockSensorEntityB01Q10(coordinator, description)
for coordinator in coordinators.b01_q10
for description in Q10_B01_SENSOR_DESCRIPTIONS
)
async_add_entities(entities)
class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
+40 -23
View File
@@ -12,13 +12,15 @@ from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProto
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
)
@@ -93,30 +95,45 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock switch platform."""
# V1 switches - using trait pattern from HEAD
async_add_entities(
[
RoborockSwitch(
f"{description.key}_{coordinator.duid_slug}",
coordinator,
description,
trait,
)
for coordinator in config_entry.runtime_data.v1
for description in SWITCH_DESCRIPTIONS
if (trait := description.trait(coordinator.properties_api)) is not None
]
)
coordinators = config_entry.runtime_data
# A01 switches
async_add_entities(
RoborockSwitchA01(
coordinator,
description,
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
entities: list[SwitchEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockSwitch(
f"{description.key}_{coordinator.duid_slug}",
coordinator,
description,
trait,
)
for description in SWITCH_DESCRIPTIONS
if (trait := description.trait(coordinator.properties_api)) is not None
)
elif isinstance(coordinator, RoborockDataUpdateCoordinatorA01):
entities.extend(
RoborockSwitchA01(
coordinator,
description,
)
for description in A01_SWITCH_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
for coordinator in config_entry.runtime_data.a01
for description in A01_SWITCH_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
+28 -5
View File
@@ -12,12 +12,17 @@ from roborock.exceptions import RoborockException
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
from .entity import RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -123,18 +128,36 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock time platform."""
async_add_entities(
[
coordinators = config_entry.runtime_data
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
if not isinstance(coordinator, RoborockDataUpdateCoordinator):
return
entities = [
RoborockTimeEntity(
f"{description.key}_{coordinator.duid_slug}",
coordinator,
description,
trait,
)
for coordinator in config_entry.runtime_data.v1
for description in TIME_DESCRIPTIONS
if (trait := description.trait(coordinator.properties_api)) is not None
]
async_add_entities(entities)
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
+25 -10
View File
@@ -24,6 +24,7 @@ from homeassistant.exceptions import (
ServiceNotSupported,
ServiceValidationError,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -31,6 +32,7 @@ from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
from .entity import (
@@ -112,16 +114,29 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock sensor."""
async_add_entities(
RoborockVacuum(coordinator) for coordinator in config_entry.runtime_data.v1
)
async_add_entities(
RoborockQ7Vacuum(coordinator)
for coordinator in config_entry.runtime_data.b01_q7
)
async_add_entities(
RoborockQ10Vacuum(coordinator)
for coordinator in config_entry.runtime_data.b01_q10
coordinators = config_entry.runtime_data
@callback
def async_add_coordinator_entities(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
if isinstance(coordinator, RoborockDataUpdateCoordinator):
async_add_entities([RoborockVacuum(coordinator)])
elif isinstance(coordinator, RoborockB01Q7UpdateCoordinator):
async_add_entities([RoborockQ7Vacuum(coordinator)])
elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
async_add_entities([RoborockQ10Vacuum(coordinator)])
for coordinator in coordinators.values():
async_add_coordinator_entities(coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"roborock_coordinator_added_{config_entry.entry_id}",
async_add_coordinator_entities,
)
)
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import Any, override
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -54,6 +54,7 @@ class SabnzbdBinarySensor(SabnzbdEntity, BinarySensorEntity):
entity_description: SabnzbdBinarySensorEntityDescription
@property
@override
def is_on(self) -> bool:
"""Return latest sensor data."""
return self.entity_description.is_on_fn(self.coordinator.data)
+2 -1
View File
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import Any, override
from pysabnzbd import SabnzbdApiException
@@ -55,6 +55,7 @@ class SabnzbdButton(SabnzbdEntity, ButtonEntity):
entity_description: SabnzbdButtonEntityDescription
@override
async def async_press(self) -> None:
"""Handle the button press."""
try:
@@ -1,7 +1,7 @@
"""Adds config flow for SabNzbd."""
import logging
from typing import Any
from typing import Any, override
import voluptuous as vol
import yarl
@@ -51,6 +51,7 @@ class SABnzbdConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle reconfiguration flow."""
return await self.async_step_user(user_input)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -2,7 +2,7 @@
from datetime import timedelta
import logging
from typing import Any
from typing import Any, override
from pysabnzbd import SabnzbdApi, SabnzbdApiException
@@ -37,6 +37,7 @@ class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
update_interval=timedelta(seconds=30),
)
@override
async def _async_update_data(self) -> dict[str, Any]:
"""Get the latest data from the SABnzbd API."""
try:
@@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import override
from pysabnzbd import SabnzbdApiException
@@ -62,10 +63,12 @@ class SabnzbdNumber(SabnzbdEntity, NumberEntity):
entity_description: SabnzbdNumberEntityDescription
@property
@override
def native_value(self) -> float:
"""Return latest value for number."""
return self.coordinator.data[self.entity_description.key]
@override
async def async_set_native_value(self, value: float) -> None:
"""Set the new number value."""
try:
@@ -1,6 +1,7 @@
"""Support for monitoring an SABnzbd NZB client."""
from dataclasses import dataclass
from typing import override
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -127,6 +128,7 @@ class SabnzbdSensor(SabnzbdEntity, SensorEntity):
entity_description: SabnzbdSensorEntityDescription
@property
@override
def native_value(self) -> StateType:
"""Return latest sensor data."""
return self.coordinator.data.get(self.entity_description.key)
+2 -1
View File
@@ -1,7 +1,7 @@
"""Config flow for SAJ."""
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
import pysaj
import voluptuous as vol
@@ -124,6 +124,7 @@ class SAJConfigFlow(ConfigFlow, domain=DOMAIN):
return serial_number
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

Some files were not shown because too many files have changed in this diff Show More