mirror of
https://github.com/home-assistant/core.git
synced 2025-08-02 18:18:21 +00:00
Merge branch 'dev' into dev
This commit is contained in:
commit
537c0ee36b
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -46,6 +46,8 @@
|
|||||||
- This PR fixes or closes issue: fixes #
|
- This PR fixes or closes issue: fixes #
|
||||||
- This PR is related to issue:
|
- This PR is related to issue:
|
||||||
- Link to documentation pull request:
|
- Link to documentation pull request:
|
||||||
|
- Link to developer documentation pull request:
|
||||||
|
- Link to frontend pull request:
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
<!--
|
<!--
|
||||||
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@ -625,8 +625,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/hlk_sw16/ @jameshilliard
|
/tests/components/hlk_sw16/ @jameshilliard
|
||||||
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
|
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
|
||||||
/tests/components/holiday/ @jrieger @gjohansson-ST
|
/tests/components/holiday/ @jrieger @gjohansson-ST
|
||||||
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98
|
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
|
||||||
/tests/components/home_connect/ @DavidMStraub @Diegorro98
|
/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
|
||||||
/homeassistant/components/homeassistant/ @home-assistant/core
|
/homeassistant/components/homeassistant/ @home-assistant/core
|
||||||
/tests/components/homeassistant/ @home-assistant/core
|
/tests/components/homeassistant/ @home-assistant/core
|
||||||
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
||||||
|
@ -161,6 +161,16 @@ FRONTEND_INTEGRATIONS = {
|
|||||||
# integrations can be removed and database migration status is
|
# integrations can be removed and database migration status is
|
||||||
# visible in frontend
|
# visible in frontend
|
||||||
"frontend",
|
"frontend",
|
||||||
|
# Hassio is an after dependency of backup, after dependencies
|
||||||
|
# are not promoted from stage 2 to earlier stages, so we need to
|
||||||
|
# add it here. Hassio needs to be setup before backup, otherwise
|
||||||
|
# the backup integration will think we are a container/core install
|
||||||
|
# when using HAOS or Supervised install.
|
||||||
|
"hassio",
|
||||||
|
# Backup is an after dependency of frontend, after dependencies
|
||||||
|
# are not promoted from stage 2 to earlier stages, so we need to
|
||||||
|
# add it here.
|
||||||
|
"backup",
|
||||||
}
|
}
|
||||||
RECORDER_INTEGRATIONS = {
|
RECORDER_INTEGRATIONS = {
|
||||||
# Setup after frontend
|
# Setup after frontend
|
||||||
|
@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self.async_create_entry(title=discovery.name, data={})
|
return self.async_create_entry(title=discovery.name, data={})
|
||||||
|
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery_info in async_discovered_service_info(self.hass):
|
for discovery_info in async_discovered_service_info(self.hass):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=self._discovered_devices[address][0], data={}
|
title=self._discovered_devices[address][0], data={}
|
||||||
)
|
)
|
||||||
|
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -33,7 +33,7 @@ from homeassistant.components.tts import (
|
|||||||
from homeassistant.const import MATCH_ALL
|
from homeassistant.const import MATCH_ALL
|
||||||
from homeassistant.core import Context, HomeAssistant, callback
|
from homeassistant.core import Context, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import chat_session, intent
|
||||||
from homeassistant.helpers.collection import (
|
from homeassistant.helpers.collection import (
|
||||||
CHANGE_UPDATED,
|
CHANGE_UPDATED,
|
||||||
CollectionError,
|
CollectionError,
|
||||||
@ -1094,13 +1094,18 @@ class PipelineRun:
|
|||||||
|
|
||||||
# It was already handled, create response and add to chat history
|
# It was already handled, create response and add to chat history
|
||||||
if intent_response is not None:
|
if intent_response is not None:
|
||||||
async with conversation.async_get_chat_session(
|
with (
|
||||||
self.hass, user_input
|
chat_session.async_get_chat_session(
|
||||||
) as chat_session:
|
self.hass, user_input.conversation_id
|
||||||
|
) as session,
|
||||||
|
conversation.async_get_chat_log(
|
||||||
|
self.hass, session, user_input
|
||||||
|
) as chat_log,
|
||||||
|
):
|
||||||
speech: str = intent_response.speech.get("plain", {}).get(
|
speech: str = intent_response.speech.get("plain", {}).get(
|
||||||
"speech", ""
|
"speech", ""
|
||||||
)
|
)
|
||||||
chat_session.async_add_message(
|
chat_log.async_add_message(
|
||||||
conversation.Content(
|
conversation.Content(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@ -1109,7 +1114,7 @@ class PipelineRun:
|
|||||||
)
|
)
|
||||||
conversation_result = conversation.ConversationResult(
|
conversation_result = conversation.ConversationResult(
|
||||||
response=intent_response,
|
response=intent_response,
|
||||||
conversation_id=chat_session.conversation_id,
|
conversation_id=session.conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
await self.async_start_conversation(announcement)
|
await self.async_start_conversation(announcement)
|
||||||
finally:
|
finally:
|
||||||
self._is_announcing = False
|
self._is_announcing = False
|
||||||
self._extra_system_prompt = None
|
|
||||||
|
|
||||||
async def async_start_conversation(
|
async def async_start_conversation(
|
||||||
self, start_announcement: AssistSatelliteAnnouncement
|
self, start_announcement: AssistSatelliteAnnouncement
|
||||||
@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
||||||
await self._cancel_running_pipeline()
|
await self._cancel_running_pipeline()
|
||||||
|
|
||||||
|
# Consume system prompt in first pipeline
|
||||||
|
extra_system_prompt = self._extra_system_prompt
|
||||||
|
self._extra_system_prompt = None
|
||||||
|
|
||||||
if self._wake_word_intercept_future and start_stage in (
|
if self._wake_word_intercept_future and start_stage in (
|
||||||
PipelineStage.WAKE_WORD,
|
PipelineStage.WAKE_WORD,
|
||||||
PipelineStage.STT,
|
PipelineStage.STT,
|
||||||
@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
),
|
),
|
||||||
start_stage=start_stage,
|
start_stage=start_stage,
|
||||||
end_stage=end_stage,
|
end_stage=end_stage,
|
||||||
conversation_extra_system_prompt=self._extra_system_prompt,
|
conversation_extra_system_prompt=extra_system_prompt,
|
||||||
),
|
),
|
||||||
f"{self.entity_id}_pipeline",
|
f"{self.entity_id}_pipeline",
|
||||||
)
|
)
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
"services": {
|
"services": {
|
||||||
"announce": {
|
"announce": {
|
||||||
"name": "Announce",
|
"name": "Announce",
|
||||||
"description": "Let the satellite announce a message.",
|
"description": "Lets a satellite announce a message.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"message": {
|
"message": {
|
||||||
"name": "Message",
|
"name": "Message",
|
||||||
@ -27,8 +27,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"start_conversation": {
|
"start_conversation": {
|
||||||
"name": "Start Conversation",
|
"name": "Start conversation",
|
||||||
"description": "Start a conversation from a satellite.",
|
"description": "Starts a conversation from a satellite.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"start_message": {
|
"start_message": {
|
||||||
"name": "Message",
|
"name": "Message",
|
||||||
|
@ -35,6 +35,7 @@ from .manager import (
|
|||||||
WrittenBackup,
|
WrittenBackup,
|
||||||
)
|
)
|
||||||
from .models import AddonInfo, AgentBackup, Folder
|
from .models import AddonInfo, AgentBackup, Folder
|
||||||
|
from .util import suggested_filename, suggested_filename_from_name_date
|
||||||
from .websocket import async_register_websocket_handlers
|
from .websocket import async_register_websocket_handlers
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -58,6 +59,8 @@ __all__ = [
|
|||||||
"RestoreBackupState",
|
"RestoreBackupState",
|
||||||
"WrittenBackup",
|
"WrittenBackup",
|
||||||
"async_get_manager",
|
"async_get_manager",
|
||||||
|
"suggested_filename",
|
||||||
|
"suggested_filename_from_name_date",
|
||||||
]
|
]
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio
|
|||||||
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
|
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
from .models import AgentBackup
|
from .models import AgentBackup
|
||||||
from .util import read_backup
|
from .util import read_backup, suggested_filename
|
||||||
|
|
||||||
|
|
||||||
async def async_get_backup_agents(
|
async def async_get_backup_agents(
|
||||||
@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
|||||||
|
|
||||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||||
"""Return the local path to a new backup."""
|
"""Return the local path to a new backup."""
|
||||||
return self._backup_dir / f"{backup.backup_id}.tar"
|
return self._backup_dir / suggested_filename(backup)
|
||||||
|
|
||||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||||
"""Delete a backup file."""
|
"""Delete a backup file."""
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -252,7 +250,7 @@ class RetentionConfig:
|
|||||||
"""Delete backups older than days."""
|
"""Delete backups older than days."""
|
||||||
self._schedule_next(manager)
|
self._schedule_next(manager)
|
||||||
|
|
||||||
def _backups_filter(
|
def _delete_filter(
|
||||||
backups: dict[str, ManagerBackup],
|
backups: dict[str, ManagerBackup],
|
||||||
) -> dict[str, ManagerBackup]:
|
) -> dict[str, ManagerBackup]:
|
||||||
"""Return backups older than days to delete."""
|
"""Return backups older than days to delete."""
|
||||||
@ -269,7 +267,9 @@ class RetentionConfig:
|
|||||||
< now
|
< now
|
||||||
}
|
}
|
||||||
|
|
||||||
await _delete_filtered_backups(manager, _backups_filter)
|
await manager.async_delete_filtered_backups(
|
||||||
|
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||||
|
)
|
||||||
|
|
||||||
manager.remove_next_delete_event = async_call_later(
|
manager.remove_next_delete_event = async_call_later(
|
||||||
manager.hass, timedelta(days=1), _delete_backups
|
manager.hass, timedelta(days=1), _delete_backups
|
||||||
@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
|
|||||||
password: str | None
|
password: str | None
|
||||||
|
|
||||||
|
|
||||||
async def _delete_filtered_backups(
|
def _automatic_backups_filter(
|
||||||
manager: BackupManager,
|
backups: dict[str, ManagerBackup],
|
||||||
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
) -> dict[str, ManagerBackup]:
|
||||||
) -> None:
|
"""Return automatic backups."""
|
||||||
"""Delete backups parsed with a filter.
|
return {
|
||||||
|
|
||||||
:param manager: The backup manager.
|
|
||||||
:param backup_filter: A filter that should return the backups to delete.
|
|
||||||
"""
|
|
||||||
backups, get_agent_errors = await manager.async_get_backups()
|
|
||||||
if get_agent_errors:
|
|
||||||
LOGGER.debug(
|
|
||||||
"Error getting backups; continuing anyway: %s",
|
|
||||||
get_agent_errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
# only delete backups that are created with the saved automatic settings
|
|
||||||
backups = {
|
|
||||||
backup_id: backup
|
backup_id: backup
|
||||||
for backup_id, backup in backups.items()
|
for backup_id, backup in backups.items()
|
||||||
if backup.with_automatic_settings
|
if backup.with_automatic_settings
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug("Total automatic backups: %s", backups)
|
|
||||||
|
|
||||||
filtered_backups = backup_filter(backups)
|
|
||||||
|
|
||||||
if not filtered_backups:
|
|
||||||
return
|
|
||||||
|
|
||||||
# always delete oldest backup first
|
|
||||||
filtered_backups = dict(
|
|
||||||
sorted(
|
|
||||||
filtered_backups.items(),
|
|
||||||
key=lambda backup_item: backup_item[1].date,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(filtered_backups) >= len(backups):
|
|
||||||
# Never delete the last backup.
|
|
||||||
last_backup = filtered_backups.popitem()
|
|
||||||
LOGGER.debug("Keeping the last backup: %s", last_backup)
|
|
||||||
|
|
||||||
LOGGER.debug("Backups to delete: %s", filtered_backups)
|
|
||||||
|
|
||||||
if not filtered_backups:
|
|
||||||
return
|
|
||||||
|
|
||||||
backup_ids = list(filtered_backups)
|
|
||||||
delete_results = await asyncio.gather(
|
|
||||||
*(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
|
|
||||||
)
|
|
||||||
agent_errors = {
|
|
||||||
backup_id: error
|
|
||||||
for backup_id, error in zip(backup_ids, delete_results, strict=True)
|
|
||||||
if error
|
|
||||||
}
|
|
||||||
if agent_errors:
|
|
||||||
LOGGER.error(
|
|
||||||
"Error deleting old copies: %s",
|
|
||||||
agent_errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
|
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
|
||||||
"""Delete backups exceeding the configured retention count."""
|
"""Delete backups exceeding the configured retention count."""
|
||||||
|
|
||||||
def _backups_filter(
|
def _delete_filter(
|
||||||
backups: dict[str, ManagerBackup],
|
backups: dict[str, ManagerBackup],
|
||||||
) -> dict[str, ManagerBackup]:
|
) -> dict[str, ManagerBackup]:
|
||||||
"""Return oldest backups more numerous than copies to delete."""
|
"""Return oldest backups more numerous than copies to delete."""
|
||||||
@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
|
|||||||
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
|
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
|
||||||
)
|
)
|
||||||
|
|
||||||
await _delete_filtered_backups(manager, _backups_filter)
|
await manager.async_delete_filtered_backups(
|
||||||
|
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||||
|
)
|
||||||
|
@ -685,6 +685,70 @@ class BackupManager:
|
|||||||
|
|
||||||
return agent_errors
|
return agent_errors
|
||||||
|
|
||||||
|
async def async_delete_filtered_backups(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
||||||
|
delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
||||||
|
) -> None:
|
||||||
|
"""Delete backups parsed with a filter.
|
||||||
|
|
||||||
|
:param include_filter: A filter that should return the backups to consider for
|
||||||
|
deletion. Note: The newest of the backups returned by include_filter will
|
||||||
|
unconditionally be kept, even if delete_filter returns all backups.
|
||||||
|
:param delete_filter: A filter that should return the backups to delete.
|
||||||
|
"""
|
||||||
|
backups, get_agent_errors = await self.async_get_backups()
|
||||||
|
if get_agent_errors:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Error getting backups; continuing anyway: %s",
|
||||||
|
get_agent_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the include filter first to ensure we only consider backups that
|
||||||
|
# should be included in the deletion process.
|
||||||
|
backups = include_filter(backups)
|
||||||
|
|
||||||
|
LOGGER.debug("Total automatic backups: %s", backups)
|
||||||
|
|
||||||
|
backups_to_delete = delete_filter(backups)
|
||||||
|
|
||||||
|
if not backups_to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# always delete oldest backup first
|
||||||
|
backups_to_delete = dict(
|
||||||
|
sorted(
|
||||||
|
backups_to_delete.items(),
|
||||||
|
key=lambda backup_item: backup_item[1].date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(backups_to_delete) >= len(backups):
|
||||||
|
# Never delete the last backup.
|
||||||
|
last_backup = backups_to_delete.popitem()
|
||||||
|
LOGGER.debug("Keeping the last backup: %s", last_backup)
|
||||||
|
|
||||||
|
LOGGER.debug("Backups to delete: %s", backups_to_delete)
|
||||||
|
|
||||||
|
if not backups_to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
backup_ids = list(backups_to_delete)
|
||||||
|
delete_results = await asyncio.gather(
|
||||||
|
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
|
||||||
|
)
|
||||||
|
agent_errors = {
|
||||||
|
backup_id: error
|
||||||
|
for backup_id, error in zip(backup_ids, delete_results, strict=True)
|
||||||
|
if error
|
||||||
|
}
|
||||||
|
if agent_errors:
|
||||||
|
LOGGER.error(
|
||||||
|
"Error deleting old copies: %s",
|
||||||
|
agent_errors,
|
||||||
|
)
|
||||||
|
|
||||||
async def async_receive_backup(
|
async def async_receive_backup(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -898,7 +962,7 @@ class BackupManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
backup_name = (
|
backup_name = (
|
||||||
name
|
(name if name is None else name.strip())
|
||||||
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
||||||
)
|
)
|
||||||
extra_metadata = extra_metadata or {}
|
extra_metadata = extra_metadata or {}
|
||||||
|
@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
|||||||
from homeassistant.backup_restore import password_to_key
|
from homeassistant.backup_restore import password_to_key
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
from homeassistant.util.thread import ThreadWithException
|
from homeassistant.util.thread import ThreadWithException
|
||||||
|
|
||||||
@ -117,6 +118,17 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_filename_from_name_date(name: str, date_str: str) -> str:
|
||||||
|
"""Suggest a filename for the backup."""
|
||||||
|
date = dt_util.parse_datetime(date_str, raise_on_error=True)
|
||||||
|
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_filename(backup: AgentBackup) -> str:
|
||||||
|
"""Suggest a filename for the backup."""
|
||||||
|
return suggested_filename_from_name_date(backup.name, backup.date)
|
||||||
|
|
||||||
|
|
||||||
def validate_password(path: Path, password: str | None) -> bool:
|
def validate_password(path: Path, password: str | None) -> bool:
|
||||||
"""Validate the password."""
|
"""Validate the password."""
|
||||||
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
|
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
|
||||||
|
@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download(
|
|||||||
vol.Optional("include_database", default=True): bool,
|
vol.Optional("include_database", default=True): bool,
|
||||||
vol.Optional("include_folders"): [vol.Coerce(Folder)],
|
vol.Optional("include_folders"): [vol.Coerce(Folder)],
|
||||||
vol.Optional("include_homeassistant", default=True): bool,
|
vol.Optional("include_homeassistant", default=True): bool,
|
||||||
vol.Optional("name"): str,
|
vol.Optional("name"): vol.Any(str, None),
|
||||||
vol.Optional("password"): vol.Any(str, None),
|
vol.Optional("password"): vol.Any(str, None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -80,6 +80,7 @@ from .const import (
|
|||||||
CONF_DETAILS,
|
CONF_DETAILS,
|
||||||
CONF_PASSIVE,
|
CONF_PASSIVE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -297,7 +298,12 @@ async def async_discover_adapters(
|
|||||||
|
|
||||||
|
|
||||||
async def async_update_device(
|
async def async_update_device(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
adapter: str,
|
||||||
|
details: AdapterDetails,
|
||||||
|
via_device_domain: str | None = None,
|
||||||
|
via_device_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update device registry entry.
|
"""Update device registry entry.
|
||||||
|
|
||||||
@ -306,7 +312,8 @@ async def async_update_device(
|
|||||||
update the device with the new location so they can
|
update the device with the new location so they can
|
||||||
figure out where the adapter is.
|
figure out where the adapter is.
|
||||||
"""
|
"""
|
||||||
dr.async_get(hass).async_get_or_create(
|
device_registry = dr.async_get(hass)
|
||||||
|
device_entry = device_registry.async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
||||||
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
||||||
@ -315,6 +322,10 @@ async def async_update_device(
|
|||||||
sw_version=details.get(ADAPTER_SW_VERSION),
|
sw_version=details.get(ADAPTER_SW_VERSION),
|
||||||
hw_version=details.get(ADAPTER_HW_VERSION),
|
hw_version=details.get(ADAPTER_HW_VERSION),
|
||||||
)
|
)
|
||||||
|
if via_device_id:
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device_entry.id, via_device_id=via_device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
entry,
|
entry,
|
||||||
source_entry.title,
|
source_entry.title,
|
||||||
details,
|
details,
|
||||||
|
source_domain,
|
||||||
|
entry.data.get(CONF_SOURCE_DEVICE_ID),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
manager = _get_manager(hass)
|
manager = _get_manager(hass)
|
||||||
|
@ -181,10 +181,16 @@ def async_register_scanner(
|
|||||||
source_domain: str | None = None,
|
source_domain: str | None = None,
|
||||||
source_model: str | None = None,
|
source_model: str | None = None,
|
||||||
source_config_entry_id: str | None = None,
|
source_config_entry_id: str | None = None,
|
||||||
|
source_device_id: str | None = None,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Register a BleakScanner."""
|
"""Register a BleakScanner."""
|
||||||
return _get_manager(hass).async_register_hass_scanner(
|
return _get_manager(hass).async_register_hass_scanner(
|
||||||
scanner, connection_slots, source_domain, source_model, source_config_entry_id
|
scanner,
|
||||||
|
connection_slots,
|
||||||
|
source_domain,
|
||||||
|
source_model,
|
||||||
|
source_config_entry_id,
|
||||||
|
source_device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ from .const import (
|
|||||||
CONF_PASSIVE,
|
CONF_PASSIVE,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
|
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
|
||||||
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
|
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
|
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
|
||||||
|
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
|
||||||
}
|
}
|
||||||
self._abort_if_unique_id_configured(updates=data)
|
self._abort_if_unique_id_configured(updates=data)
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
|
|||||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||||
CONF_SOURCE_MODEL: Final = "source_model"
|
CONF_SOURCE_MODEL: Final = "source_model"
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||||
|
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
|
||||||
|
|
||||||
SOURCE_LOCAL: Final = "local"
|
SOURCE_LOCAL: Final = "local"
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
source_domain: str | None = None,
|
source_domain: str | None = None,
|
||||||
source_model: str | None = None,
|
source_model: str | None = None,
|
||||||
source_config_entry_id: str | None = None,
|
source_config_entry_id: str | None = None,
|
||||||
|
source_device_id: str | None = None,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Register a scanner."""
|
"""Register a scanner."""
|
||||||
cancel = self.async_register_scanner(scanner, connection_slots)
|
cancel = self.async_register_scanner(scanner, connection_slots)
|
||||||
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
isinstance(scanner, BaseHaRemoteScanner)
|
isinstance(scanner, BaseHaRemoteScanner)
|
||||||
and source_domain
|
and source_domain
|
||||||
and source_config_entry_id
|
and source_config_entry_id
|
||||||
and not self.hass.config_entries.async_entry_for_domain_unique_id(
|
|
||||||
DOMAIN, scanner.source
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
self.hass.async_create_task(
|
self.hass.async_create_task(
|
||||||
self.hass.config_entries.flow.async_init(
|
self.hass.config_entries.flow.async_init(
|
||||||
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
CONF_SOURCE_DOMAIN: source_domain,
|
CONF_SOURCE_DOMAIN: source_domain,
|
||||||
CONF_SOURCE_MODEL: source_model,
|
CONF_SOURCE_MODEL: source_model,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
|
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
|
||||||
|
CONF_SOURCE_DEVICE_ID: source_device_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -19,8 +19,8 @@
|
|||||||
"bleak-retry-connector==3.8.0",
|
"bleak-retry-connector==3.8.0",
|
||||||
"bluetooth-adapters==0.21.1",
|
"bluetooth-adapters==0.21.1",
|
||||||
"bluetooth-auto-recovery==1.4.2",
|
"bluetooth-auto-recovery==1.4.2",
|
||||||
"bluetooth-data-tools==1.22.0",
|
"bluetooth-data-tools==1.23.3",
|
||||||
"dbus-fast==2.30.2",
|
"dbus-fast==2.31.0",
|
||||||
"habluetooth==3.14.0"
|
"habluetooth==3.21.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_EMAIL
|
from homeassistant.const import CONF_EMAIL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
|
|
||||||
config_entry: ConfigEntry
|
config_entry: ConfigEntry
|
||||||
user_settings: BringUserSettingsResponse
|
user_settings: BringUserSettingsResponse
|
||||||
|
lists: list[BringList]
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
|
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
|
||||||
"""Initialize the Bring data coordinator."""
|
"""Initialize the Bring data coordinator."""
|
||||||
@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
update_interval=timedelta(seconds=90),
|
update_interval=timedelta(seconds=90),
|
||||||
)
|
)
|
||||||
self.bring = bring
|
self.bring = bring
|
||||||
|
self.previous_lists: set[str] = set()
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, BringData]:
|
async def _async_update_data(self) -> dict[str, BringData]:
|
||||||
|
"""Fetch the latest data from bring."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lists_response = await self.bring.load_lists()
|
self.lists = (await self.bring.load_lists()).lists
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
|
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
|
||||||
except BringParseException as e:
|
except BringParseException as e:
|
||||||
@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
) from exc
|
) from exc
|
||||||
return self.data
|
return self.data
|
||||||
|
|
||||||
|
if self.previous_lists - (
|
||||||
|
current_lists := {lst.listUuid for lst in self.lists}
|
||||||
|
):
|
||||||
|
self._purge_deleted_lists()
|
||||||
|
self.previous_lists = current_lists
|
||||||
|
|
||||||
list_dict: dict[str, BringData] = {}
|
list_dict: dict[str, BringData] = {}
|
||||||
for lst in lists_response.lists:
|
for lst in self.lists:
|
||||||
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
|
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
try:
|
try:
|
||||||
await self.bring.login()
|
await self.bring.login()
|
||||||
self.user_settings = await self.bring.get_all_user_settings()
|
self.user_settings = await self.bring.get_all_user_settings()
|
||||||
|
self.lists = (await self.bring.load_lists()).lists
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
translation_key="setup_authentication_exception",
|
translation_key="setup_authentication_exception",
|
||||||
translation_placeholders={CONF_EMAIL: self.bring.mail},
|
translation_placeholders={CONF_EMAIL: self.bring.mail},
|
||||||
) from e
|
) from e
|
||||||
|
self._purge_deleted_lists()
|
||||||
|
|
||||||
|
def _purge_deleted_lists(self) -> None:
|
||||||
|
"""Purge device entries of deleted lists."""
|
||||||
|
|
||||||
|
device_reg = dr.async_get(self.hass)
|
||||||
|
identifiers = {
|
||||||
|
(DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}")
|
||||||
|
for lst in self.lists
|
||||||
|
}
|
||||||
|
for device in dr.async_entries_for_config_entry(
|
||||||
|
device_reg, self.config_entry.entry_id
|
||||||
|
):
|
||||||
|
if not set(device.identifiers) & identifiers:
|
||||||
|
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||||
|
device_reg.async_update_device(
|
||||||
|
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||||
|
)
|
||||||
|
@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from bring_api.types import BringList
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import BringData, BringDataUpdateCoordinator
|
from .coordinator import BringDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
||||||
@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BringDataUpdateCoordinator,
|
coordinator: BringDataUpdateCoordinator,
|
||||||
bring_list: BringData,
|
bring_list: BringList,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(coordinator, bring_list.lst.listUuid)
|
super().__init__(coordinator, bring_list.listUuid)
|
||||||
|
|
||||||
self._list_uuid = bring_list.lst.listUuid
|
self._list_uuid = bring_list.listUuid
|
||||||
|
|
||||||
self.device_info = DeviceInfo(
|
self.device_info = DeviceInfo(
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
name=bring_list.lst.name,
|
name=bring_list.name,
|
||||||
identifiers={
|
identifiers={
|
||||||
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
|
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
|
||||||
},
|
},
|
||||||
manufacturer="Bring! Labs AG",
|
manufacturer="Bring! Labs AG",
|
||||||
model="Bring! Grocery Shopping List",
|
model="Bring! Grocery Shopping List",
|
||||||
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}",
|
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
|
||||||
)
|
)
|
||||||
|
@ -53,7 +53,7 @@ rules:
|
|||||||
docs-supported-functions: todo
|
docs-supported-functions: todo
|
||||||
docs-troubleshooting: todo
|
docs-troubleshooting: todo
|
||||||
docs-use-cases: todo
|
docs-use-cases: todo
|
||||||
dynamic-devices: todo
|
dynamic-devices: done
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
@ -65,7 +65,7 @@ rules:
|
|||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
no repairs
|
no repairs
|
||||||
stale-devices: todo
|
stale-devices: done
|
||||||
# Platinum
|
# Platinum
|
||||||
async-dependency: done
|
async-dependency: done
|
||||||
inject-websession: done
|
inject-websession: done
|
||||||
|
@ -8,6 +8,7 @@ from enum import StrEnum
|
|||||||
|
|
||||||
from bring_api import BringUserSettingsResponse
|
from bring_api import BringUserSettingsResponse
|
||||||
from bring_api.const import BRING_SUPPORTED_LOCALES
|
from bring_api.const import BRING_SUPPORTED_LOCALES
|
||||||
|
from bring_api.types import BringList
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -15,7 +16,7 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
@ -90,16 +91,28 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensor platform."""
|
"""Set up the sensor platform."""
|
||||||
coordinator = config_entry.runtime_data
|
coordinator = config_entry.runtime_data
|
||||||
|
lists_added: set[str] = set()
|
||||||
|
|
||||||
async_add_entities(
|
@callback
|
||||||
BringSensorEntity(
|
def add_entities() -> None:
|
||||||
coordinator,
|
"""Add sensor entities."""
|
||||||
bring_list,
|
nonlocal lists_added
|
||||||
description,
|
|
||||||
)
|
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
|
||||||
for description in SENSOR_DESCRIPTIONS
|
async_add_entities(
|
||||||
for bring_list in coordinator.data.values()
|
BringSensorEntity(
|
||||||
)
|
coordinator,
|
||||||
|
bring_list,
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
for description in SENSOR_DESCRIPTIONS
|
||||||
|
for bring_list in coordinator.lists
|
||||||
|
if bring_list.listUuid in new_lists
|
||||||
|
)
|
||||||
|
lists_added |= new_lists
|
||||||
|
|
||||||
|
coordinator.async_add_listener(add_entities)
|
||||||
|
add_entities()
|
||||||
|
|
||||||
|
|
||||||
class BringSensorEntity(BringBaseEntity, SensorEntity):
|
class BringSensorEntity(BringBaseEntity, SensorEntity):
|
||||||
@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BringDataUpdateCoordinator,
|
coordinator: BringDataUpdateCoordinator,
|
||||||
bring_list: BringData,
|
bring_list: BringList,
|
||||||
entity_description: BringSensorEntityDescription,
|
entity_description: BringSensorEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
|
@ -12,6 +12,7 @@ from bring_api import (
|
|||||||
BringNotificationType,
|
BringNotificationType,
|
||||||
BringRequestException,
|
BringRequestException,
|
||||||
)
|
)
|
||||||
|
from bring_api.types import BringList
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.todo import (
|
from homeassistant.components.todo import (
|
||||||
@ -20,7 +21,7 @@ from homeassistant.components.todo import (
|
|||||||
TodoListEntity,
|
TodoListEntity,
|
||||||
TodoListEntityFeature,
|
TodoListEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
@ -45,14 +46,23 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensor from a config entry created in the integrations UI."""
|
"""Set up the sensor from a config entry created in the integrations UI."""
|
||||||
coordinator = config_entry.runtime_data
|
coordinator = config_entry.runtime_data
|
||||||
|
lists_added: set[str] = set()
|
||||||
|
|
||||||
async_add_entities(
|
@callback
|
||||||
BringTodoListEntity(
|
def add_entities() -> None:
|
||||||
coordinator,
|
"""Add or remove todo list entities."""
|
||||||
bring_list=bring_list,
|
nonlocal lists_added
|
||||||
)
|
|
||||||
for bring_list in coordinator.data.values()
|
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
|
||||||
)
|
async_add_entities(
|
||||||
|
BringTodoListEntity(coordinator, bring_list)
|
||||||
|
for bring_list in coordinator.lists
|
||||||
|
if bring_list.listUuid in new_lists
|
||||||
|
)
|
||||||
|
lists_added |= new_lists
|
||||||
|
|
||||||
|
coordinator.async_add_listener(add_entities)
|
||||||
|
add_entities()
|
||||||
|
|
||||||
platform = entity_platform.async_get_current_platform()
|
platform = entity_platform.async_get_current_platform()
|
||||||
|
|
||||||
@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, coordinator: BringDataUpdateCoordinator, bring_list: BringData
|
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(coordinator, bring_list)
|
super().__init__(coordinator, bring_list)
|
||||||
|
@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self._async_get_or_create_entry()
|
return self._async_get_or_create_entry()
|
||||||
|
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==3.9.1"]
|
"requirements": ["bthome-ble==3.12.3"]
|
||||||
}
|
}
|
||||||
|
@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
# Conductivity (µS/cm)
|
||||||
|
(
|
||||||
|
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
||||||
|
Units.CONDUCTIVITY,
|
||||||
|
): SensorEntityDescription(
|
||||||
|
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
||||||
|
device_class=SensorDeviceClass.CONDUCTIVITY,
|
||||||
|
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
# Count (-)
|
# Count (-)
|
||||||
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
|
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
|
||||||
key=str(BTHomeSensorDeviceClass.COUNT),
|
key=str(BTHomeSensorDeviceClass.COUNT),
|
||||||
@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
|
# Directions (°)
|
||||||
|
(BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription(
|
||||||
|
key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}",
|
||||||
|
native_unit_of_measurement=DEGREE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
# Distance (mm)
|
# Distance (mm)
|
||||||
(
|
(
|
||||||
BTHomeSensorDeviceClass.DISTANCE,
|
BTHomeSensorDeviceClass.DISTANCE,
|
||||||
@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
|
# Precipitation (mm)
|
||||||
|
(
|
||||||
|
BTHomeExtendedSensorDeviceClass.PRECIPITATION,
|
||||||
|
Units.LENGTH_MILLIMETERS,
|
||||||
|
): SensorEntityDescription(
|
||||||
|
key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}",
|
||||||
|
device_class=SensorDeviceClass.PRECIPITATION,
|
||||||
|
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
# Pressure (mbar)
|
# Pressure (mbar)
|
||||||
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
|
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
|
||||||
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
|
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
|
||||||
@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
),
|
),
|
||||||
# Conductivity (µS/cm)
|
|
||||||
(
|
|
||||||
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
|
||||||
Units.CONDUCTIVITY,
|
|
||||||
): SensorEntityDescription(
|
|
||||||
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
|
||||||
device_class=SensorDeviceClass.CONDUCTIVITY,
|
|
||||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service(
|
|||||||
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||||
)
|
)
|
||||||
|
|
||||||
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
|
try:
|
||||||
image = (
|
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
|
||||||
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
|
image = (
|
||||||
if camera.use_stream_for_stills
|
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
|
||||||
else await camera.async_camera_image()
|
if camera.use_stream_for_stills
|
||||||
)
|
else await camera.async_camera_image()
|
||||||
|
)
|
||||||
|
except TimeoutError as err:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds"
|
||||||
|
) from err
|
||||||
|
|
||||||
if image is None:
|
if image is None:
|
||||||
return
|
return
|
||||||
@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
|
|||||||
try:
|
try:
|
||||||
await hass.async_add_executor_job(_write_image, snapshot_file, image)
|
await hass.async_add_executor_job(_write_image, snapshot_file, image)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Can't write image to file: %s", err)
|
raise HomeAssistantError(f"Can't write image to file: {err}") from err
|
||||||
|
|
||||||
|
|
||||||
async def async_handle_play_stream_service(
|
async def async_handle_play_stream_service(
|
||||||
|
@ -48,20 +48,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
|
|||||||
from .entity import ConversationEntity
|
from .entity import ConversationEntity
|
||||||
from .http import async_setup as async_setup_conversation_http
|
from .http import async_setup as async_setup_conversation_http
|
||||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||||
from .session import (
|
from .session import ChatLog, Content, ConverseError, NativeContent, async_get_chat_log
|
||||||
ChatSession,
|
|
||||||
Content,
|
|
||||||
ConverseError,
|
|
||||||
NativeContent,
|
|
||||||
async_get_chat_session,
|
|
||||||
)
|
|
||||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"HOME_ASSISTANT_AGENT",
|
"HOME_ASSISTANT_AGENT",
|
||||||
"OLD_HOME_ASSISTANT_AGENT",
|
"OLD_HOME_ASSISTANT_AGENT",
|
||||||
"ChatSession",
|
"ChatLog",
|
||||||
"Content",
|
"Content",
|
||||||
"ConversationEntity",
|
"ConversationEntity",
|
||||||
"ConversationEntityFeature",
|
"ConversationEntityFeature",
|
||||||
@ -73,7 +67,7 @@ __all__ = [
|
|||||||
"async_conversation_trace_append",
|
"async_conversation_trace_append",
|
||||||
"async_converse",
|
"async_converse",
|
||||||
"async_get_agent_info",
|
"async_get_agent_info",
|
||||||
"async_get_chat_session",
|
"async_get_chat_log",
|
||||||
"async_set_agent",
|
"async_set_agent",
|
||||||
"async_setup",
|
"async_setup",
|
||||||
"async_unset_agent",
|
"async_unset_agent",
|
||||||
|
@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
|||||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
area_registry as ar,
|
area_registry as ar,
|
||||||
|
chat_session,
|
||||||
device_registry as dr,
|
device_registry as dr,
|
||||||
entity_registry as er,
|
entity_registry as er,
|
||||||
floor_registry as fr,
|
floor_registry as fr,
|
||||||
@ -62,7 +63,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import ConversationEntity
|
from .entity import ConversationEntity
|
||||||
from .models import ConversationInput, ConversationResult
|
from .models import ConversationInput, ConversationResult
|
||||||
from .session import Content, async_get_chat_session
|
from .session import Content, async_get_chat_log
|
||||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
|
|||||||
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
||||||
"""Process a sentence."""
|
"""Process a sentence."""
|
||||||
response: intent.IntentResponse | None = None
|
response: intent.IntentResponse | None = None
|
||||||
async with async_get_chat_session(self.hass, user_input) as chat_session:
|
with (
|
||||||
|
chat_session.async_get_chat_session(
|
||||||
|
self.hass, user_input.conversation_id
|
||||||
|
) as session,
|
||||||
|
async_get_chat_log(self.hass, session, user_input) as chat_log,
|
||||||
|
):
|
||||||
# Check if a trigger matched
|
# Check if a trigger matched
|
||||||
if trigger_result := await self.async_recognize_sentence_trigger(
|
if trigger_result := await self.async_recognize_sentence_trigger(
|
||||||
user_input
|
user_input
|
||||||
@ -373,7 +379,7 @@ class DefaultAgent(ConversationEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
speech: str = response.speech.get("plain", {}).get("speech", "")
|
speech: str = response.speech.get("plain", {}).get("speech", "")
|
||||||
chat_session.async_add_message(
|
chat_log.async_add_message(
|
||||||
Content(
|
Content(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
agent_id=user_input.agent_id,
|
agent_id=user_input.agent_id,
|
||||||
@ -382,7 +388,7 @@ class DefaultAgent(ConversationEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ConversationResult(
|
return ConversationResult(
|
||||||
response=response, conversation_id=chat_session.conversation_id
|
response=response, conversation_id=session.conversation_id
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_process_intent_result(
|
async def _async_process_intent_result(
|
||||||
|
@ -2,28 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import Generator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.core import (
|
|
||||||
CALLBACK_TYPE,
|
|
||||||
Event,
|
|
||||||
HassJob,
|
|
||||||
HassJobType,
|
|
||||||
HomeAssistant,
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||||
from homeassistant.helpers import intent, llm, template
|
from homeassistant.helpers import chat_session, intent, llm, template
|
||||||
from homeassistant.helpers.event import async_call_later
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util import dt as dt_util, ulid as ulid_util
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
from homeassistant.util.json import JsonObjectType
|
from homeassistant.util.json import JsonObjectType
|
||||||
|
|
||||||
@ -31,100 +22,36 @@ from . import trace
|
|||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .models import ConversationInput, ConversationResult
|
from .models import ConversationInput, ConversationResult
|
||||||
|
|
||||||
DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey(
|
DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log")
|
||||||
"conversation_chat_session"
|
|
||||||
)
|
|
||||||
DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey(
|
|
||||||
"conversation_chat_session_cleanup"
|
|
||||||
)
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
CONVERSATION_TIMEOUT = timedelta(minutes=5)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionCleanup:
|
@contextmanager
|
||||||
"""Helper to clean up the history."""
|
def async_get_chat_log(
|
||||||
|
|
||||||
unsub: CALLBACK_TYPE | None = None
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize the history cleanup."""
|
|
||||||
self.hass = hass
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
|
|
||||||
self.cleanup_job = HassJob(
|
|
||||||
self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def schedule(self) -> None:
|
|
||||||
"""Schedule the cleanup."""
|
|
||||||
if self.unsub:
|
|
||||||
return
|
|
||||||
self.unsub = async_call_later(
|
|
||||||
self.hass,
|
|
||||||
CONVERSATION_TIMEOUT.total_seconds() + 1,
|
|
||||||
self.cleanup_job,
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _on_hass_stop(self, event: Event) -> None:
|
|
||||||
"""Cancel the cleanup on shutdown."""
|
|
||||||
if self.unsub:
|
|
||||||
self.unsub()
|
|
||||||
self.unsub = None
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _cleanup(self, now: datetime) -> None:
|
|
||||||
"""Clean up the history and schedule follow-up if necessary."""
|
|
||||||
self.unsub = None
|
|
||||||
all_history = self.hass.data[DATA_CHAT_HISTORY]
|
|
||||||
|
|
||||||
# We mutate original object because current commands could be
|
|
||||||
# yielding history based on it.
|
|
||||||
for conversation_id, history in list(all_history.items()):
|
|
||||||
if history.last_updated + CONVERSATION_TIMEOUT < now:
|
|
||||||
del all_history[conversation_id]
|
|
||||||
|
|
||||||
# Still conversations left, check again in timeout time.
|
|
||||||
if all_history:
|
|
||||||
self.schedule()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def async_get_chat_session(
|
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
session: chat_session.ChatSession,
|
||||||
user_input: ConversationInput,
|
user_input: ConversationInput,
|
||||||
) -> AsyncGenerator[ChatSession]:
|
) -> Generator[ChatLog]:
|
||||||
"""Return chat session."""
|
"""Return chat log for a specific chat session."""
|
||||||
all_history = hass.data.get(DATA_CHAT_HISTORY)
|
all_history = hass.data.get(DATA_CHAT_HISTORY)
|
||||||
if all_history is None:
|
if all_history is None:
|
||||||
all_history = {}
|
all_history = {}
|
||||||
hass.data[DATA_CHAT_HISTORY] = all_history
|
hass.data[DATA_CHAT_HISTORY] = all_history
|
||||||
hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass)
|
|
||||||
|
|
||||||
history: ChatSession | None = None
|
history = all_history.get(session.conversation_id)
|
||||||
|
|
||||||
if user_input.conversation_id is None:
|
|
||||||
conversation_id = ulid_util.ulid_now()
|
|
||||||
|
|
||||||
elif history := all_history.get(user_input.conversation_id):
|
|
||||||
conversation_id = user_input.conversation_id
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Conversation IDs are ULIDs. We generate a new one if not provided.
|
|
||||||
# If an old OLID is passed in, we will generate a new one to indicate
|
|
||||||
# a new conversation was started. If the user picks their own, they
|
|
||||||
# want to track a conversation and we respect it.
|
|
||||||
try:
|
|
||||||
ulid_util.ulid_to_bytes(user_input.conversation_id)
|
|
||||||
conversation_id = ulid_util.ulid_now()
|
|
||||||
except ValueError:
|
|
||||||
conversation_id = user_input.conversation_id
|
|
||||||
|
|
||||||
if history:
|
if history:
|
||||||
history = replace(history, messages=history.messages.copy())
|
history = replace(history, messages=history.messages.copy())
|
||||||
else:
|
else:
|
||||||
history = ChatSession(hass, conversation_id, user_input.agent_id)
|
history = ChatLog(hass, session.conversation_id, user_input.agent_id)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def do_cleanup() -> None:
|
||||||
|
"""Handle cleanup."""
|
||||||
|
all_history.pop(session.conversation_id)
|
||||||
|
|
||||||
|
session.async_on_cleanup(do_cleanup)
|
||||||
|
|
||||||
message: Content = Content(
|
message: Content = Content(
|
||||||
role="user",
|
role="user",
|
||||||
@ -142,8 +69,7 @@ async def async_get_chat_session(
|
|||||||
return
|
return
|
||||||
|
|
||||||
history.last_updated = dt_util.utcnow()
|
history.last_updated = dt_util.utcnow()
|
||||||
all_history[conversation_id] = history
|
all_history[session.conversation_id] = history
|
||||||
hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule()
|
|
||||||
|
|
||||||
|
|
||||||
class ConverseError(HomeAssistantError):
|
class ConverseError(HomeAssistantError):
|
||||||
@ -187,8 +113,8 @@ class NativeContent[_NativeT]:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChatSession[_NativeT]:
|
class ChatLog[_NativeT]:
|
||||||
"""Class holding all information for a specific conversation."""
|
"""Class holding the chat history of a specific conversation."""
|
||||||
|
|
||||||
hass: HomeAssistant
|
hass: HomeAssistant
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
],
|
],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aiodhcpwatcher==1.0.2",
|
"aiodhcpwatcher==1.0.3",
|
||||||
"aiodiscover==2.1.0",
|
"aiodiscover==2.1.0",
|
||||||
"cached-ipaddress==0.8.0"
|
"cached-ipaddress==0.8.0"
|
||||||
]
|
]
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||||
"requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"]
|
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"]
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
from eheimdigital.heater import EheimDigitalHeater
|
from eheimdigital.heater import EheimDigitalHeater
|
||||||
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
|
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
|
||||||
|
|
||||||
@ -39,17 +40,23 @@ async def async_setup_entry(
|
|||||||
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
|
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
async def async_setup_device_entities(device_address: str) -> None:
|
def async_setup_device_entities(
|
||||||
"""Set up the light entities for a device."""
|
device_address: str | dict[str, EheimDigitalDevice],
|
||||||
device = coordinator.hub.devices[device_address]
|
) -> None:
|
||||||
|
"""Set up the climate entities for one or multiple devices."""
|
||||||
|
entities: list[EheimDigitalHeaterClimate] = []
|
||||||
|
if isinstance(device_address, str):
|
||||||
|
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||||
|
for device in device_address.values():
|
||||||
|
if isinstance(device, EheimDigitalHeater):
|
||||||
|
entities.append(EheimDigitalHeaterClimate(coordinator, device))
|
||||||
|
coordinator.known_devices.add(device.mac_address)
|
||||||
|
|
||||||
if isinstance(device, EheimDigitalHeater):
|
async_add_entities(entities)
|
||||||
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
|
|
||||||
|
|
||||||
coordinator.add_platform_callback(async_setup_device_entities)
|
coordinator.add_platform_callback(async_setup_device_entities)
|
||||||
|
|
||||||
for device_address in entry.runtime_data.hub.devices:
|
async_setup_device_entities(coordinator.hub.devices)
|
||||||
await async_setup_device_entities(device_address)
|
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
|
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
|
||||||
@ -69,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
|
|||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_preset_mode = PRESET_NONE
|
_attr_preset_mode = PRESET_NONE
|
||||||
_attr_translation_key = "heater"
|
_attr_translation_key = "heater"
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
|
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
from eheimdigital.device import EheimDigitalDevice
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
|||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
|
type AsyncSetupDeviceEntitiesCallback = Callable[
|
||||||
|
[str | dict[str, EheimDigitalDevice]], None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalUpdateCoordinator(
|
class EheimDigitalUpdateCoordinator(
|
||||||
@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator(
|
|||||||
|
|
||||||
if device_address not in self.known_devices:
|
if device_address not in self.known_devices:
|
||||||
for platform_callback in self.platform_callbacks:
|
for platform_callback in self.platform_callbacks:
|
||||||
await platform_callback(device_address)
|
platform_callback(device_address)
|
||||||
|
|
||||||
async def _async_receive_callback(self) -> None:
|
async def _async_receive_callback(self) -> None:
|
||||||
self.async_set_updated_data(self.hub.devices)
|
self.async_set_updated_data(self.hub.devices)
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
|
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
|
||||||
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
from eheimdigital.types import EheimDigitalClientError, LightMode
|
from eheimdigital.types import EheimDigitalClientError, LightMode
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
@ -37,24 +38,28 @@ async def async_setup_entry(
|
|||||||
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
|
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
async def async_setup_device_entities(device_address: str) -> None:
|
def async_setup_device_entities(
|
||||||
"""Set up the light entities for a device."""
|
device_address: str | dict[str, EheimDigitalDevice],
|
||||||
device = coordinator.hub.devices[device_address]
|
) -> None:
|
||||||
|
"""Set up the light entities for one or multiple devices."""
|
||||||
entities: list[EheimDigitalClassicLEDControlLight] = []
|
entities: list[EheimDigitalClassicLEDControlLight] = []
|
||||||
|
if isinstance(device_address, str):
|
||||||
|
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||||
|
for device in device_address.values():
|
||||||
|
if isinstance(device, EheimDigitalClassicLEDControl):
|
||||||
|
for channel in range(2):
|
||||||
|
if len(device.tankconfig[channel]) > 0:
|
||||||
|
entities.append(
|
||||||
|
EheimDigitalClassicLEDControlLight(
|
||||||
|
coordinator, device, channel
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coordinator.known_devices.add(device.mac_address)
|
||||||
|
|
||||||
if isinstance(device, EheimDigitalClassicLEDControl):
|
|
||||||
for channel in range(2):
|
|
||||||
if len(device.tankconfig[channel]) > 0:
|
|
||||||
entities.append(
|
|
||||||
EheimDigitalClassicLEDControlLight(coordinator, device, channel)
|
|
||||||
)
|
|
||||||
coordinator.known_devices.add(device.mac_address)
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
coordinator.add_platform_callback(async_setup_device_entities)
|
coordinator.add_platform_callback(async_setup_device_entities)
|
||||||
|
async_setup_device_entities(coordinator.hub.devices)
|
||||||
for device_address in entry.runtime_data.hub.devices:
|
|
||||||
await async_setup_device_entities(device_address)
|
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalClassicLEDControlLight(
|
class EheimDigitalClassicLEDControlLight(
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Searching for Energenie-Power-Sockets Devices.",
|
"title": "Searching for Energenie Power Sockets devices",
|
||||||
"description": "Choose a discovered device.",
|
"description": "Choose a discovered device.",
|
||||||
"data": {
|
"data": {
|
||||||
"device": "[%key:common::config_flow::data::device%]"
|
"device": "[%key:common::config_flow::data::device%]"
|
||||||
@ -13,7 +13,7 @@
|
|||||||
"abort": {
|
"abort": {
|
||||||
"usb_error": "Couldn't access USB devices!",
|
"usb_error": "Couldn't access USB devices!",
|
||||||
"no_device": "Unable to discover any (new) supported device.",
|
"no_device": "Unable to discover any (new) supported device.",
|
||||||
"device_not_found": "No device was found for the given id.",
|
"device_not_found": "No device was found for the given ID.",
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -22,5 +22,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eq3btsmart"],
|
"loggers": ["eq3btsmart"],
|
||||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"]
|
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"]
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ def async_connect_scanner(
|
|||||||
entry_data: RuntimeEntryData,
|
entry_data: RuntimeEntryData,
|
||||||
cli: APIClient,
|
cli: APIClient,
|
||||||
device_info: DeviceInfo,
|
device_info: DeviceInfo,
|
||||||
|
device_id: str,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Connect scanner."""
|
"""Connect scanner."""
|
||||||
client_data = connect_scanner(cli, device_info, entry_data.available)
|
client_data = connect_scanner(cli, device_info, entry_data.available)
|
||||||
@ -45,6 +46,7 @@ def async_connect_scanner(
|
|||||||
source_domain=DOMAIN,
|
source_domain=DOMAIN,
|
||||||
source_model=device_info.model,
|
source_model=device_info.model,
|
||||||
source_config_entry_id=entry_data.entry_id,
|
source_config_entry_id=entry_data.entry_id,
|
||||||
|
source_device_id=device_id,
|
||||||
),
|
),
|
||||||
scanner.async_setup(),
|
scanner.async_setup(),
|
||||||
],
|
],
|
||||||
|
@ -425,7 +425,9 @@ class ESPHomeManager:
|
|||||||
|
|
||||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||||
entry_data.disconnect_callbacks.add(
|
entry_data.disconnect_callbacks.add(
|
||||||
async_connect_scanner(hass, entry_data, cli, device_info)
|
async_connect_scanner(
|
||||||
|
hass, entry_data, cli, device_info, self.device_id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
bluetooth.async_remove_scanner(hass, device_info.mac_address)
|
bluetooth.async_remove_scanner(hass, device_info.mac_address)
|
||||||
@ -571,7 +573,9 @@ def _async_setup_device_registry(
|
|||||||
|
|
||||||
configuration_url = None
|
configuration_url = None
|
||||||
if device_info.webserver_port > 0:
|
if device_info.webserver_port > 0:
|
||||||
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
|
entry_host = entry.data["host"]
|
||||||
|
host = f"[{entry_host}]" if ":" in entry_host else entry_host
|
||||||
|
configuration_url = f"http://{host}:{device_info.webserver_port}"
|
||||||
elif (
|
elif (
|
||||||
(dashboard := async_get_dashboard(hass))
|
(dashboard := async_get_dashboard(hass))
|
||||||
and dashboard.data
|
and dashboard.data
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==29.0.0",
|
"aioesphomeapi==29.0.0",
|
||||||
"esphome-dashboard-api==1.2.3",
|
"esphome-dashboard-api==1.2.3",
|
||||||
"bleak-esphome==2.2.0"
|
"bleak-esphome==2.6.0"
|
||||||
],
|
],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."]
|
"zeroconf": ["_esphomelib._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -21,5 +21,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20250130.0"]
|
"requirements": ["home-assistant-frontend==20250131.0"]
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
|
|||||||
value, extra_state_attributes = self.entity_description.state_fn(value)
|
value, extra_state_attributes = self.entity_description.state_fn(value)
|
||||||
|
|
||||||
if self.entity_description.round_state_value:
|
if self.entity_description.round_state_value:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(value, int)
|
||||||
value = round_storage(value)
|
value = round_storage(value)
|
||||||
|
|
||||||
self._attr_native_value = value
|
self._attr_native_value = value
|
||||||
|
@ -11,7 +11,7 @@ from aiohttp import ClientSession, ClientTimeout, StreamReader
|
|||||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||||
from google_drive_api.api import AbstractAuth, GoogleDriveApi
|
from google_drive_api.api import AbstractAuth, GoogleDriveApi
|
||||||
|
|
||||||
from homeassistant.components.backup import AgentBackup
|
from homeassistant.components.backup import AgentBackup, suggested_filename
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
from homeassistant.exceptions import (
|
from homeassistant.exceptions import (
|
||||||
@ -132,7 +132,7 @@ class DriveClient:
|
|||||||
"""Upload a backup."""
|
"""Upload a backup."""
|
||||||
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
|
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
|
||||||
backup_metadata = {
|
backup_metadata = {
|
||||||
"name": f"{backup.name} {backup.date}.tar",
|
"name": suggested_filename(backup),
|
||||||
"description": json.dumps(backup.as_dict()),
|
"description": json.dumps(backup.as_dict()),
|
||||||
"parents": [folder_id],
|
"parents": [folder_id],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from homeassistant.components.application_credentials import AuthorizationServer
|
from homeassistant.components.application_credentials import AuthorizationServer
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
|
||||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||||
@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s
|
|||||||
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
|
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
|
||||||
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
|
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
|
||||||
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
|
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
|
||||||
|
"redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"application_credentials": {
|
"application_credentials": {
|
||||||
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
|
"description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -209,15 +209,18 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
self, user_input: conversation.ConversationInput
|
self, user_input: conversation.ConversationInput
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Process a sentence."""
|
"""Process a sentence."""
|
||||||
async with conversation.async_get_chat_session(
|
with (
|
||||||
self.hass, user_input
|
chat_session.async_get_chat_session(
|
||||||
) as session:
|
self.hass, user_input.conversation_id
|
||||||
return await self._async_handle_message(user_input, session)
|
) as session,
|
||||||
|
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
|
||||||
|
):
|
||||||
|
return await self._async_handle_message(user_input, chat_log)
|
||||||
|
|
||||||
async def _async_handle_message(
|
async def _async_handle_message(
|
||||||
self,
|
self,
|
||||||
user_input: conversation.ConversationInput,
|
user_input: conversation.ConversationInput,
|
||||||
session: conversation.ChatSession[genai_types.ContentDict],
|
session: conversation.ChatLog[genai_types.ContentDict],
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Call the API."""
|
"""Call the API."""
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=title, data={CONF_DEVICE_TYPE: device.device_type}
|
title=title, data={CONF_DEVICE_TYPE: device.device_type}
|
||||||
)
|
)
|
||||||
|
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["habiticalib"],
|
"loggers": ["habiticalib"],
|
||||||
"requirements": ["habiticalib==0.3.3"]
|
"requirements": ["habiticalib==0.3.4"]
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import asyncio
|
|||||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePath
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -33,16 +33,20 @@ from homeassistant.components.backup import (
|
|||||||
Folder,
|
Folder,
|
||||||
IdleEvent,
|
IdleEvent,
|
||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
|
ManagerBackup,
|
||||||
NewBackup,
|
NewBackup,
|
||||||
RestoreBackupEvent,
|
RestoreBackupEvent,
|
||||||
RestoreBackupState,
|
RestoreBackupState,
|
||||||
WrittenBackup,
|
WrittenBackup,
|
||||||
async_get_manager as async_get_backup_manager,
|
async_get_manager as async_get_backup_manager,
|
||||||
|
suggested_filename as suggested_backup_filename,
|
||||||
|
suggested_filename_from_name_date,
|
||||||
)
|
)
|
||||||
from homeassistant.const import __version__ as HAVERSION
|
from homeassistant.const import __version__ as HAVERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
|
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
|
||||||
from .handler import get_supervisor_client
|
from .handler import get_supervisor_client
|
||||||
@ -51,6 +55,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup"
|
|||||||
LOCATION_LOCAL = ".local"
|
LOCATION_LOCAL = ".local"
|
||||||
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
|
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
|
||||||
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
|
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
|
||||||
|
# Set on backups automatically created when updating an addon
|
||||||
|
TAG_ADDON_UPDATE = "supervisor.addon_update"
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -113,12 +119,15 @@ def _backup_details_to_agent_backup(
|
|||||||
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
|
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
|
||||||
for addon in details.addons
|
for addon in details.addons
|
||||||
]
|
]
|
||||||
|
extra_metadata = details.extra or {}
|
||||||
location = location or LOCATION_LOCAL
|
location = location or LOCATION_LOCAL
|
||||||
return AgentBackup(
|
return AgentBackup(
|
||||||
addons=addons,
|
addons=addons,
|
||||||
backup_id=details.slug,
|
backup_id=details.slug,
|
||||||
database_included=database_included,
|
database_included=database_included,
|
||||||
date=details.date.isoformat(),
|
date=extra_metadata.get(
|
||||||
|
"supervisor.backup_request_date", details.date.isoformat()
|
||||||
|
),
|
||||||
extra_metadata=details.extra or {},
|
extra_metadata=details.extra or {},
|
||||||
folders=[Folder(folder) for folder in details.folders],
|
folders=[Folder(folder) for folder in details.folders],
|
||||||
homeassistant_included=homeassistant_included,
|
homeassistant_included=homeassistant_included,
|
||||||
@ -174,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent):
|
|||||||
return
|
return
|
||||||
stream = await open_stream()
|
stream = await open_stream()
|
||||||
upload_options = supervisor_backups.UploadBackupOptions(
|
upload_options = supervisor_backups.UploadBackupOptions(
|
||||||
location={self.location}
|
location={self.location},
|
||||||
|
filename=PurePath(suggested_backup_filename(backup)),
|
||||||
)
|
)
|
||||||
await self._client.backups.upload_backup(
|
await self._client.backups.upload_backup(
|
||||||
stream,
|
stream,
|
||||||
@ -301,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
locations = []
|
locations = []
|
||||||
locations = locations or [LOCATION_CLOUD_BACKUP]
|
locations = locations or [LOCATION_CLOUD_BACKUP]
|
||||||
|
|
||||||
|
date = dt_util.now().isoformat()
|
||||||
|
extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
|
||||||
|
filename = suggested_filename_from_name_date(backup_name, date)
|
||||||
try:
|
try:
|
||||||
backup = await self._client.backups.partial_backup(
|
backup = await self._client.backups.partial_backup(
|
||||||
supervisor_backups.PartialBackupOptions(
|
supervisor_backups.PartialBackupOptions(
|
||||||
@ -314,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
homeassistant_exclude_database=not include_database,
|
homeassistant_exclude_database=not include_database,
|
||||||
background=True,
|
background=True,
|
||||||
extra=extra_metadata,
|
extra=extra_metadata,
|
||||||
|
filename=PurePath(filename),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except SupervisorError as err:
|
except SupervisorError as err:
|
||||||
@ -614,10 +628,20 @@ async def backup_addon_before_update(
|
|||||||
else:
|
else:
|
||||||
password = None
|
password = None
|
||||||
|
|
||||||
|
def addon_update_backup_filter(
|
||||||
|
backups: dict[str, ManagerBackup],
|
||||||
|
) -> dict[str, ManagerBackup]:
|
||||||
|
"""Return addon update backups."""
|
||||||
|
return {
|
||||||
|
backup_id: backup
|
||||||
|
for backup_id, backup in backups.items()
|
||||||
|
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await backup_manager.async_create_backup(
|
await backup_manager.async_create_backup(
|
||||||
agent_ids=[await _default_agent(client)],
|
agent_ids=[await _default_agent(client)],
|
||||||
extra_metadata={"supervisor.addon_update": addon},
|
extra_metadata={TAG_ADDON_UPDATE: addon},
|
||||||
include_addons=[addon],
|
include_addons=[addon],
|
||||||
include_all_addons=False,
|
include_all_addons=False,
|
||||||
include_database=False,
|
include_database=False,
|
||||||
@ -628,6 +652,14 @@ async def backup_addon_before_update(
|
|||||||
)
|
)
|
||||||
except BackupManagerError as err:
|
except BackupManagerError as err:
|
||||||
raise HomeAssistantError(f"Error creating backup: {err}") from err
|
raise HomeAssistantError(f"Error creating backup: {err}") from err
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await backup_manager.async_delete_filtered_backups(
|
||||||
|
include_filter=addon_update_backup_filter,
|
||||||
|
delete_filter=lambda backups: backups,
|
||||||
|
)
|
||||||
|
except BackupManagerError as err:
|
||||||
|
raise HomeAssistantError(f"Error deleting old backups: {err}") from err
|
||||||
|
|
||||||
|
|
||||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||||
|
@ -6,7 +6,7 @@ import logging
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from aiohomeconnect.client import Client as HomeConnectClient
|
from aiohomeconnect.client import Client as HomeConnectClient
|
||||||
from aiohomeconnect.model import CommandKey, Option, OptionKey
|
from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
|
||||||
from aiohomeconnect.model.error import HomeConnectError
|
from aiohomeconnect.model.error import HomeConnectError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@ -50,7 +50,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|||||||
SERVICE_SETTING_SCHEMA = vol.Schema(
|
SERVICE_SETTING_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_DEVICE_ID): str,
|
vol.Required(ATTR_DEVICE_ID): str,
|
||||||
vol.Required(ATTR_KEY): str,
|
vol.Required(ATTR_KEY): vol.All(
|
||||||
|
vol.Coerce(SettingKey),
|
||||||
|
vol.NotIn([SettingKey.UNKNOWN]),
|
||||||
|
),
|
||||||
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -58,7 +61,10 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
|
|||||||
SERVICE_OPTION_SCHEMA = vol.Schema(
|
SERVICE_OPTION_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_DEVICE_ID): str,
|
vol.Required(ATTR_DEVICE_ID): str,
|
||||||
vol.Required(ATTR_KEY): str,
|
vol.Required(ATTR_KEY): vol.All(
|
||||||
|
vol.Coerce(OptionKey),
|
||||||
|
vol.NotIn([OptionKey.UNKNOWN]),
|
||||||
|
),
|
||||||
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
||||||
vol.Optional(ATTR_UNIT): str,
|
vol.Optional(ATTR_UNIT): str,
|
||||||
}
|
}
|
||||||
@ -67,14 +73,23 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
|
|||||||
SERVICE_PROGRAM_SCHEMA = vol.Any(
|
SERVICE_PROGRAM_SCHEMA = vol.Any(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_DEVICE_ID): str,
|
vol.Required(ATTR_DEVICE_ID): str,
|
||||||
vol.Required(ATTR_PROGRAM): str,
|
vol.Required(ATTR_PROGRAM): vol.All(
|
||||||
vol.Required(ATTR_KEY): str,
|
vol.Coerce(ProgramKey),
|
||||||
|
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||||
|
),
|
||||||
|
vol.Required(ATTR_KEY): vol.All(
|
||||||
|
vol.Coerce(OptionKey),
|
||||||
|
vol.NotIn([OptionKey.UNKNOWN]),
|
||||||
|
),
|
||||||
vol.Required(ATTR_VALUE): vol.Any(int, str),
|
vol.Required(ATTR_VALUE): vol.Any(int, str),
|
||||||
vol.Optional(ATTR_UNIT): str,
|
vol.Optional(ATTR_UNIT): str,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_DEVICE_ID): str,
|
vol.Required(ATTR_DEVICE_ID): str,
|
||||||
vol.Required(ATTR_PROGRAM): str,
|
vol.Required(ATTR_PROGRAM): vol.All(
|
||||||
|
vol.Coerce(ProgramKey),
|
||||||
|
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -141,7 +156,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
options = (
|
options = (
|
||||||
[
|
[
|
||||||
Option(
|
Option(
|
||||||
OptionKey(option_key),
|
option_key,
|
||||||
call.data[ATTR_VALUE],
|
call.data[ATTR_VALUE],
|
||||||
unit=call.data.get(ATTR_UNIT),
|
unit=call.data.get(ATTR_UNIT),
|
||||||
)
|
)
|
||||||
@ -178,14 +193,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
if active:
|
if active:
|
||||||
await client.set_active_program_option(
|
await client.set_active_program_option(
|
||||||
ha_id,
|
ha_id,
|
||||||
option_key=OptionKey(option_key),
|
option_key=option_key,
|
||||||
value=value,
|
value=value,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await client.set_selected_program_option(
|
await client.set_selected_program_option(
|
||||||
ha_id,
|
ha_id,
|
||||||
option_key=OptionKey(option_key),
|
option_key=option_key,
|
||||||
value=value,
|
value=value,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
)
|
)
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
|
|||||||
async_delete_issue,
|
async_delete_issue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_DOOR_STATE_CLOSED,
|
BSH_DOOR_STATE_CLOSED,
|
||||||
BSH_DOOR_STATE_LOCKED,
|
BSH_DOOR_STATE_LOCKED,
|
||||||
@ -113,24 +114,33 @@ BINARY_SENSORS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
entities: list[HomeConnectEntity] = []
|
||||||
|
entities.extend(
|
||||||
|
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
|
||||||
|
for description in BINARY_SENSORS
|
||||||
|
if description.key in appliance.status
|
||||||
|
)
|
||||||
|
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
|
||||||
|
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect binary sensor."""
|
"""Set up the Home Connect binary sensor."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[BinarySensorEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
|
)
|
||||||
for description in BINARY_SENSORS
|
|
||||||
if description.key in appliance.status
|
|
||||||
)
|
|
||||||
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
|
|
||||||
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||||
|
99
homeassistant/components/home_connect/common.py
Normal file
99
homeassistant/components/home_connect/common.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""Common callbacks for all Home Connect platforms."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import partial
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from aiohomeconnect.model import EventKey
|
||||||
|
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
|
from .entity import HomeConnectEntity
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_paired_or_connected_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
known_entity_unique_ids: dict[str, str],
|
||||||
|
get_entities_for_appliance: Callable[
|
||||||
|
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||||
|
],
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a new paired appliance or an appliance that has been connected.
|
||||||
|
|
||||||
|
This function is used to handle connected events also, because some appliances
|
||||||
|
don't report any data while they are off because they disconnect themselves
|
||||||
|
when they are turned off, so we need to check if the entities have been added
|
||||||
|
already or it is the first time we see them when the appliance is connected.
|
||||||
|
"""
|
||||||
|
entities: list[HomeConnectEntity] = []
|
||||||
|
for appliance in entry.runtime_data.data.values():
|
||||||
|
entities_to_add = [
|
||||||
|
entity
|
||||||
|
for entity in get_entities_for_appliance(entry, appliance)
|
||||||
|
if entity.unique_id not in known_entity_unique_ids
|
||||||
|
]
|
||||||
|
known_entity_unique_ids.update(
|
||||||
|
{
|
||||||
|
cast(str, entity.unique_id): appliance.info.ha_id
|
||||||
|
for entity in entities_to_add
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entities.extend(entities_to_add)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_depaired_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
known_entity_unique_ids: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Handle a removed appliance."""
|
||||||
|
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
|
||||||
|
if appliance_id not in entry.runtime_data.data:
|
||||||
|
known_entity_unique_ids.pop(entity_unique_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_home_connect_entry(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
get_entities_for_appliance: Callable[
|
||||||
|
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||||
|
],
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the callbacks for paired and depaired appliances."""
|
||||||
|
known_entity_unique_ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
entities: list[HomeConnectEntity] = []
|
||||||
|
for appliance in entry.runtime_data.data.values():
|
||||||
|
entities_to_add = get_entities_for_appliance(entry, appliance)
|
||||||
|
known_entity_unique_ids.update(
|
||||||
|
{
|
||||||
|
cast(str, entity.unique_id): appliance.info.ha_id
|
||||||
|
for entity in entities_to_add
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entities.extend(entities_to_add)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
entry.runtime_data.async_add_special_listener(
|
||||||
|
partial(
|
||||||
|
_handle_paired_or_connected_appliance,
|
||||||
|
entry,
|
||||||
|
known_entity_unique_ids,
|
||||||
|
get_entities_for_appliance,
|
||||||
|
async_add_entities,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EventKey.BSH_COMMON_APPLIANCE_PAIRED,
|
||||||
|
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entry.async_on_unload(
|
||||||
|
entry.runtime_data.async_add_special_listener(
|
||||||
|
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
|
||||||
|
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
|
||||||
|
)
|
||||||
|
)
|
@ -3,7 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -25,11 +25,12 @@ from aiohomeconnect.model.error import (
|
|||||||
HomeConnectError,
|
HomeConnectError,
|
||||||
HomeConnectRequestError,
|
HomeConnectRequestError,
|
||||||
)
|
)
|
||||||
from aiohomeconnect.model.program import EnumerateAvailableProgram
|
from aiohomeconnect.model.program import EnumerateProgram
|
||||||
from propcache.api import cached_property
|
from propcache.api import cached_property
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||||
@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30
|
|||||||
class HomeConnectApplianceData:
|
class HomeConnectApplianceData:
|
||||||
"""Class to hold Home Connect appliance data."""
|
"""Class to hold Home Connect appliance data."""
|
||||||
|
|
||||||
events: dict[EventKey, Event] = field(default_factory=dict)
|
events: dict[EventKey, Event]
|
||||||
info: HomeAppliance
|
info: HomeAppliance
|
||||||
programs: list[EnumerateAvailableProgram] = field(default_factory=list)
|
programs: list[EnumerateProgram]
|
||||||
settings: dict[SettingKey, GetSetting]
|
settings: dict[SettingKey, GetSetting]
|
||||||
status: dict[StatusKey, Status]
|
status: dict[StatusKey, Status]
|
||||||
|
|
||||||
@ -83,6 +84,10 @@ class HomeConnectCoordinator(
|
|||||||
name=config_entry.entry_id,
|
name=config_entry.entry_id,
|
||||||
)
|
)
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self._special_listeners: dict[
|
||||||
|
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||||
|
] = {}
|
||||||
|
self.device_registry = dr.async_get(self.hass)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||||
@ -107,6 +112,28 @@ class HomeConnectCoordinator(
|
|||||||
|
|
||||||
return remove_listener_and_invalidate_context_listeners
|
return remove_listener_and_invalidate_context_listeners
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_special_listener(
|
||||||
|
self,
|
||||||
|
update_callback: CALLBACK_TYPE,
|
||||||
|
context: tuple[EventKey, ...],
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Listen for special data updates.
|
||||||
|
|
||||||
|
These listeners will not be called on refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def remove_listener() -> None:
|
||||||
|
"""Remove update listener."""
|
||||||
|
self._special_listeners.pop(remove_listener)
|
||||||
|
if not self._special_listeners:
|
||||||
|
self._unschedule_refresh()
|
||||||
|
|
||||||
|
self._special_listeners[remove_listener] = (update_callback, context)
|
||||||
|
|
||||||
|
return remove_listener
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def start_event_listener(self) -> None:
|
def start_event_listener(self) -> None:
|
||||||
"""Start event listener."""
|
"""Start event listener."""
|
||||||
@ -121,9 +148,10 @@ class HomeConnectCoordinator(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async for event_message in self.client.stream_all_events():
|
async for event_message in self.client.stream_all_events():
|
||||||
|
event_message_ha_id = event_message.ha_id
|
||||||
match event_message.type:
|
match event_message.type:
|
||||||
case EventType.STATUS:
|
case EventType.STATUS:
|
||||||
statuses = self.data[event_message.ha_id].status
|
statuses = self.data[event_message_ha_id].status
|
||||||
for event in event_message.data.items:
|
for event in event_message.data.items:
|
||||||
status_key = StatusKey(event.key)
|
status_key = StatusKey(event.key)
|
||||||
if status_key in statuses:
|
if status_key in statuses:
|
||||||
@ -134,10 +162,11 @@ class HomeConnectCoordinator(
|
|||||||
raw_key=status_key.value,
|
raw_key=status_key.value,
|
||||||
value=event.value,
|
value=event.value,
|
||||||
)
|
)
|
||||||
|
self._call_event_listener(event_message)
|
||||||
|
|
||||||
case EventType.NOTIFY:
|
case EventType.NOTIFY:
|
||||||
settings = self.data[event_message.ha_id].settings
|
settings = self.data[event_message_ha_id].settings
|
||||||
events = self.data[event_message.ha_id].events
|
events = self.data[event_message_ha_id].events
|
||||||
for event in event_message.data.items:
|
for event in event_message.data.items:
|
||||||
if event.key in SettingKey:
|
if event.key in SettingKey:
|
||||||
setting_key = SettingKey(event.key)
|
setting_key = SettingKey(event.key)
|
||||||
@ -151,13 +180,56 @@ class HomeConnectCoordinator(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
events[event.key] = event
|
events[event.key] = event
|
||||||
|
self._call_event_listener(event_message)
|
||||||
|
|
||||||
case EventType.EVENT:
|
case EventType.EVENT:
|
||||||
events = self.data[event_message.ha_id].events
|
events = self.data[event_message_ha_id].events
|
||||||
for event in event_message.data.items:
|
for event in event_message.data.items:
|
||||||
events[event.key] = event
|
events[event.key] = event
|
||||||
|
self._call_event_listener(event_message)
|
||||||
|
|
||||||
self._call_event_listener(event_message)
|
case EventType.CONNECTED | EventType.PAIRED:
|
||||||
|
appliance_info = await self.client.get_specific_appliance(
|
||||||
|
event_message_ha_id
|
||||||
|
)
|
||||||
|
|
||||||
|
appliance_data = await self._get_appliance_data(
|
||||||
|
appliance_info, self.data.get(appliance_info.ha_id)
|
||||||
|
)
|
||||||
|
if event_message_ha_id in self.data:
|
||||||
|
self.data[event_message_ha_id].update(appliance_data)
|
||||||
|
else:
|
||||||
|
self.data[event_message_ha_id] = appliance_data
|
||||||
|
for listener, context in list(
|
||||||
|
self._special_listeners.values()
|
||||||
|
) + list(self._listeners.values()):
|
||||||
|
assert isinstance(context, tuple)
|
||||||
|
if (
|
||||||
|
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
|
||||||
|
not in context
|
||||||
|
):
|
||||||
|
listener()
|
||||||
|
|
||||||
|
case EventType.DISCONNECTED:
|
||||||
|
self.data[event_message_ha_id].info.connected = False
|
||||||
|
self._call_all_event_listeners_for_appliance(
|
||||||
|
event_message_ha_id
|
||||||
|
)
|
||||||
|
|
||||||
|
case EventType.DEPAIRED:
|
||||||
|
device = self.device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, event_message_ha_id)}
|
||||||
|
)
|
||||||
|
if device:
|
||||||
|
self.device_registry.async_update_device(
|
||||||
|
device_id=device.id,
|
||||||
|
remove_config_entry_id=self.config_entry.entry_id,
|
||||||
|
)
|
||||||
|
self.data.pop(event_message_ha_id, None)
|
||||||
|
for listener, context in self._special_listeners.values():
|
||||||
|
assert isinstance(context, tuple)
|
||||||
|
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||||
|
listener()
|
||||||
|
|
||||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
@ -186,6 +258,12 @@ class HomeConnectCoordinator(
|
|||||||
):
|
):
|
||||||
listener()
|
listener()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _call_all_event_listeners_for_appliance(self, ha_id: str):
|
||||||
|
for listener, context in self._listeners.values():
|
||||||
|
if isinstance(context, tuple) and context[0] == ha_id:
|
||||||
|
listener()
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
||||||
"""Fetch data from Home Connect."""
|
"""Fetch data from Home Connect."""
|
||||||
try:
|
try:
|
||||||
@ -197,62 +275,101 @@ class HomeConnectCoordinator(
|
|||||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
appliances_data = self.data or {}
|
return {
|
||||||
for appliance in appliances.homeappliances:
|
appliance.ha_id: await self._get_appliance_data(
|
||||||
try:
|
appliance, self.data.get(appliance.ha_id) if self.data else None
|
||||||
settings = {
|
|
||||||
setting.key: setting
|
|
||||||
for setting in (
|
|
||||||
await self.client.get_settings(appliance.ha_id)
|
|
||||||
).settings
|
|
||||||
}
|
|
||||||
except HomeConnectError as error:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Error fetching settings for %s: %s",
|
|
||||||
appliance.ha_id,
|
|
||||||
error
|
|
||||||
if isinstance(error, HomeConnectApiError)
|
|
||||||
else type(error).__name__,
|
|
||||||
)
|
|
||||||
settings = {}
|
|
||||||
try:
|
|
||||||
status = {
|
|
||||||
status.key: status
|
|
||||||
for status in (await self.client.get_status(appliance.ha_id)).status
|
|
||||||
}
|
|
||||||
except HomeConnectError as error:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Error fetching status for %s: %s",
|
|
||||||
appliance.ha_id,
|
|
||||||
error
|
|
||||||
if isinstance(error, HomeConnectApiError)
|
|
||||||
else type(error).__name__,
|
|
||||||
)
|
|
||||||
status = {}
|
|
||||||
appliance_data = HomeConnectApplianceData(
|
|
||||||
info=appliance, settings=settings, status=status
|
|
||||||
)
|
)
|
||||||
if appliance.ha_id in appliances_data:
|
for appliance in appliances.homeappliances
|
||||||
appliances_data[appliance.ha_id].update(appliance_data)
|
}
|
||||||
appliance_data = appliances_data[appliance.ha_id]
|
|
||||||
|
async def _get_appliance_data(
|
||||||
|
self,
|
||||||
|
appliance: HomeAppliance,
|
||||||
|
appliance_data_to_update: HomeConnectApplianceData | None = None,
|
||||||
|
) -> HomeConnectApplianceData:
|
||||||
|
"""Get appliance data."""
|
||||||
|
self.device_registry.async_get_or_create(
|
||||||
|
config_entry_id=self.config_entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, appliance.ha_id)},
|
||||||
|
manufacturer=appliance.brand,
|
||||||
|
name=appliance.name,
|
||||||
|
model=appliance.vib,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
settings = {
|
||||||
|
setting.key: setting
|
||||||
|
for setting in (
|
||||||
|
await self.client.get_settings(appliance.ha_id)
|
||||||
|
).settings
|
||||||
|
}
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching settings for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
|
settings = {}
|
||||||
|
try:
|
||||||
|
status = {
|
||||||
|
status.key: status
|
||||||
|
for status in (await self.client.get_status(appliance.ha_id)).status
|
||||||
|
}
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching status for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
|
status = {}
|
||||||
|
|
||||||
|
programs = []
|
||||||
|
events = {}
|
||||||
|
if appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||||
|
try:
|
||||||
|
all_programs = await self.client.get_all_programs(appliance.ha_id)
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching programs for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
appliances_data[appliance.ha_id] = appliance_data
|
programs.extend(all_programs.programs)
|
||||||
if (
|
for program, event_key in (
|
||||||
appliance.type in APPLIANCES_WITH_PROGRAMS
|
(
|
||||||
and not appliance_data.programs
|
all_programs.active,
|
||||||
):
|
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||||
try:
|
),
|
||||||
appliance_data.programs.extend(
|
(
|
||||||
(
|
all_programs.selected,
|
||||||
await self.client.get_available_programs(appliance.ha_id)
|
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||||
).programs
|
),
|
||||||
)
|
):
|
||||||
except HomeConnectError as error:
|
if program and program.key:
|
||||||
_LOGGER.debug(
|
events[event_key] = Event(
|
||||||
"Error fetching programs for %s: %s",
|
event_key,
|
||||||
appliance.ha_id,
|
event_key.value,
|
||||||
error
|
0,
|
||||||
if isinstance(error, HomeConnectApiError)
|
"",
|
||||||
else type(error).__name__,
|
"",
|
||||||
)
|
program.key,
|
||||||
return appliances_data
|
)
|
||||||
|
|
||||||
|
appliance_data = HomeConnectApplianceData(
|
||||||
|
events=events,
|
||||||
|
info=appliance,
|
||||||
|
programs=programs,
|
||||||
|
settings=settings,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
if appliance_data_to_update:
|
||||||
|
appliance_data_to_update.update(appliance_data)
|
||||||
|
appliance_data = appliance_data_to_update
|
||||||
|
|
||||||
|
return appliance_data
|
||||||
|
@ -35,9 +35,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
|||||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, appliance.info.ha_id)},
|
identifiers={(DOMAIN, appliance.info.ha_id)},
|
||||||
manufacturer=appliance.info.brand,
|
|
||||||
model=appliance.info.vib,
|
|
||||||
name=appliance.info.name,
|
|
||||||
)
|
)
|
||||||
self.update_native_value()
|
self.update_native_value()
|
||||||
|
|
||||||
@ -56,3 +53,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
|||||||
def bsh_key(self) -> str:
|
def bsh_key(self) -> str:
|
||||||
"""Return the BSH key."""
|
"""Return the BSH key."""
|
||||||
return self.entity_description.key
|
return self.entity_description.key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return (
|
||||||
|
self.appliance.info.connected and self._attr_available and super().available
|
||||||
|
)
|
||||||
|
@ -20,6 +20,7 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import color as color_util
|
from homeassistant.util import color as color_util
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -78,20 +79,28 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectLight(entry.runtime_data, appliance, description)
|
||||||
|
for description in LIGHTS
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect light."""
|
"""Set up the Home Connect light."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
[
|
_get_entities_for_appliance,
|
||||||
HomeConnectLight(entry.runtime_data, appliance, description)
|
async_add_entities,
|
||||||
for description in LIGHTS
|
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "home_connect",
|
"domain": "home_connect",
|
||||||
"name": "Home Connect",
|
"name": "Home Connect",
|
||||||
"codeowners": ["@DavidMStraub", "@Diegorro98"],
|
"codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["application_credentials"],
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["homeconnect"],
|
"loggers": ["aiohomeconnect"],
|
||||||
"requirements": ["aiohomeconnect==0.12.1"]
|
"requirements": ["aiohomeconnect==0.12.3"]
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||||
@ -22,7 +23,7 @@ from .const import (
|
|||||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
from .utils import get_dict_from_home_connect_error
|
from .utils import get_dict_from_home_connect_error
|
||||||
|
|
||||||
@ -78,19 +79,28 @@ NUMBERS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
||||||
|
for description in NUMBERS
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect number."""
|
"""Set up the Home Connect number."""
|
||||||
async_add_entities(
|
setup_home_connect_entry(
|
||||||
[
|
entry,
|
||||||
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
_get_entities_for_appliance,
|
||||||
for description in NUMBERS
|
async_add_entities,
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,15 +1,20 @@
|
|||||||
"""Provides a select platform for Home Connect."""
|
"""Provides a select platform for Home Connect."""
|
||||||
|
|
||||||
from typing import cast
|
from collections.abc import Callable, Coroutine
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from aiohomeconnect.client import Client as HomeConnectClient
|
||||||
from aiohomeconnect.model import EventKey, ProgramKey
|
from aiohomeconnect.model import EventKey, ProgramKey
|
||||||
from aiohomeconnect.model.error import HomeConnectError
|
from aiohomeconnect.model.error import HomeConnectError
|
||||||
|
from aiohomeconnect.model.program import Execution
|
||||||
|
|
||||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
|
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
HomeConnectApplianceData,
|
HomeConnectApplianceData,
|
||||||
@ -29,41 +34,80 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
|
|||||||
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
|
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class HomeConnectProgramSelectEntityDescription(
|
||||||
|
SelectEntityDescription,
|
||||||
|
):
|
||||||
|
"""Entity Description class for select entities for programs."""
|
||||||
|
|
||||||
|
allowed_executions: tuple[Execution, ...]
|
||||||
|
set_program_fn: Callable[
|
||||||
|
[HomeConnectClient, str, ProgramKey], Coroutine[Any, Any, None]
|
||||||
|
]
|
||||||
|
error_translation_key: str
|
||||||
|
|
||||||
|
|
||||||
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
||||||
SelectEntityDescription(
|
HomeConnectProgramSelectEntityDescription(
|
||||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||||
translation_key="active_program",
|
translation_key="active_program",
|
||||||
|
allowed_executions=(Execution.SELECT_AND_START, Execution.START_ONLY),
|
||||||
|
set_program_fn=lambda client, ha_id, program_key: client.start_program(
|
||||||
|
ha_id, program_key=program_key
|
||||||
|
),
|
||||||
|
error_translation_key="start_program",
|
||||||
),
|
),
|
||||||
SelectEntityDescription(
|
HomeConnectProgramSelectEntityDescription(
|
||||||
key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||||
translation_key="selected_program",
|
translation_key="selected_program",
|
||||||
|
allowed_executions=(Execution.SELECT_AND_START, Execution.SELECT_ONLY),
|
||||||
|
set_program_fn=lambda client, ha_id, program_key: client.set_selected_program(
|
||||||
|
ha_id, program_key=program_key
|
||||||
|
),
|
||||||
|
error_translation_key="select_program",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
||||||
|
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
||||||
|
]
|
||||||
|
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect select entities."""
|
"""Set up the Home Connect select entities."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
_get_entities_for_appliance,
|
||||||
for appliance in entry.runtime_data.data.values()
|
async_add_entities,
|
||||||
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
|
||||||
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||||
"""Select class for Home Connect programs."""
|
"""Select class for Home Connect programs."""
|
||||||
|
|
||||||
|
entity_description: HomeConnectProgramSelectEntityDescription
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: HomeConnectCoordinator,
|
coordinator: HomeConnectCoordinator,
|
||||||
appliance: HomeConnectApplianceData,
|
appliance: HomeConnectApplianceData,
|
||||||
desc: SelectEntityDescription,
|
desc: HomeConnectProgramSelectEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@ -75,9 +119,11 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
|||||||
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
|
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
|
||||||
for program in appliance.programs
|
for program in appliance.programs
|
||||||
if program.key != ProgramKey.UNKNOWN
|
if program.key != ProgramKey.UNKNOWN
|
||||||
|
and (
|
||||||
|
program.constraints is None
|
||||||
|
or program.constraints.execution in desc.allowed_executions
|
||||||
|
)
|
||||||
]
|
]
|
||||||
self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
|
|
||||||
self._attr_current_option = None
|
|
||||||
|
|
||||||
def update_native_value(self) -> None:
|
def update_native_value(self) -> None:
|
||||||
"""Set the program value."""
|
"""Set the program value."""
|
||||||
@ -92,22 +138,15 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
|||||||
"""Select new program."""
|
"""Select new program."""
|
||||||
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
|
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
|
||||||
try:
|
try:
|
||||||
if self.start_on_select:
|
await self.entity_description.set_program_fn(
|
||||||
await self.coordinator.client.start_program(
|
self.coordinator.client,
|
||||||
self.appliance.info.ha_id, program_key=program_key
|
self.appliance.info.ha_id,
|
||||||
)
|
program_key,
|
||||||
else:
|
)
|
||||||
await self.coordinator.client.set_selected_program(
|
|
||||||
self.appliance.info.ha_id, program_key=program_key
|
|
||||||
)
|
|
||||||
except HomeConnectError as err:
|
except HomeConnectError as err:
|
||||||
if self.start_on_select:
|
|
||||||
translation_key = "start_program"
|
|
||||||
else:
|
|
||||||
translation_key = "select_program"
|
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key=translation_key,
|
translation_key=self.entity_description.error_translation_key,
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
**get_dict_from_home_connect_error(err),
|
**get_dict_from_home_connect_error(err),
|
||||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
|
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
|
||||||
|
@ -17,13 +17,14 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util, slugify
|
from homeassistant.util import dt as dt_util, slugify
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
APPLIANCES_WITH_PROGRAMS,
|
APPLIANCES_WITH_PROGRAMS,
|
||||||
BSH_OPERATION_STATE_FINISHED,
|
BSH_OPERATION_STATE_FINISHED,
|
||||||
BSH_OPERATION_STATE_PAUSE,
|
BSH_OPERATION_STATE_PAUSE,
|
||||||
BSH_OPERATION_STATE_RUN,
|
BSH_OPERATION_STATE_RUN,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
|
|
||||||
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
||||||
@ -243,37 +244,42 @@ EVENT_SENSORS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
*[
|
||||||
|
HomeConnectEventSensor(entry.runtime_data, appliance, description)
|
||||||
|
for description in EVENT_SENSORS
|
||||||
|
if description.appliance_types
|
||||||
|
and appliance.info.type in description.appliance_types
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
|
||||||
|
for desc in BSH_PROGRAM_SENSORS
|
||||||
|
if desc.appliance_types and appliance.info.type in desc.appliance_types
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
HomeConnectSensor(entry.runtime_data, appliance, description)
|
||||||
|
for description in SENSORS
|
||||||
|
if description.key in appliance.status
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect sensor."""
|
"""Set up the Home Connect sensor."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[SensorEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
HomeConnectEventSensor(
|
)
|
||||||
entry.runtime_data,
|
|
||||||
appliance,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
for description in EVENT_SENSORS
|
|
||||||
if description.appliance_types
|
|
||||||
and appliance.info.type in description.appliance_types
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
|
|
||||||
for desc in BSH_PROGRAM_SENSORS
|
|
||||||
if desc.appliance_types and appliance.info.type in desc.appliance_types
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectSensor(entry.runtime_data, appliance, description)
|
|
||||||
for description in SENSORS
|
|
||||||
if description.key in appliance.status
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||||
|
@ -5,7 +5,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
|
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
|
||||||
from aiohomeconnect.model.error import HomeConnectError
|
from aiohomeconnect.model.error import HomeConnectError
|
||||||
from aiohomeconnect.model.program import EnumerateAvailableProgram
|
from aiohomeconnect.model.program import EnumerateProgram
|
||||||
|
|
||||||
from homeassistant.components.automation import automations_with_entity
|
from homeassistant.components.automation import automations_with_entity
|
||||||
from homeassistant.components.script import scripts_with_entity
|
from homeassistant.components.script import scripts_with_entity
|
||||||
@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_POWER_OFF,
|
BSH_POWER_OFF,
|
||||||
BSH_POWER_ON,
|
BSH_POWER_ON,
|
||||||
@ -100,33 +101,43 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
entities: list[HomeConnectEntity] = []
|
||||||
|
entities.extend(
|
||||||
|
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
|
||||||
|
for program in appliance.programs
|
||||||
|
if program.key != ProgramKey.UNKNOWN
|
||||||
|
)
|
||||||
|
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
|
||||||
|
entities.append(
|
||||||
|
HomeConnectPowerSwitch(
|
||||||
|
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entities.extend(
|
||||||
|
HomeConnectSwitch(entry.runtime_data, appliance, description)
|
||||||
|
for description in SWITCHES
|
||||||
|
if description.key in appliance.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect switch."""
|
"""Set up the Home Connect switch."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[SwitchEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
|
)
|
||||||
for program in appliance.programs
|
|
||||||
if program.key != ProgramKey.UNKNOWN
|
|
||||||
)
|
|
||||||
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
|
|
||||||
entities.append(
|
|
||||||
HomeConnectPowerSwitch(
|
|
||||||
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
|
|
||||||
)
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectSwitch(entry.runtime_data, appliance, description)
|
|
||||||
for description in SWITCHES
|
|
||||||
if description.key in appliance.settings
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||||
@ -184,7 +195,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
|||||||
self,
|
self,
|
||||||
coordinator: HomeConnectCoordinator,
|
coordinator: HomeConnectCoordinator,
|
||||||
appliance: HomeConnectApplianceData,
|
appliance: HomeConnectApplianceData,
|
||||||
program: EnumerateAvailableProgram,
|
program: EnumerateProgram,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
desc = " ".join(["Program", program.key.split(".")[-1]])
|
desc = " ".join(["Program", program.key.split(".")[-1]])
|
||||||
@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
|||||||
desc = " ".join(
|
desc = " ".join(
|
||||||
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
|
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
|
||||||
)
|
)
|
||||||
|
self.program = program
|
||||||
super().__init__(
|
super().__init__(
|
||||||
coordinator,
|
coordinator,
|
||||||
appliance,
|
appliance,
|
||||||
@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
|||||||
self._attr_name = f"{appliance.info.name} {desc}"
|
self._attr_name = f"{appliance.info.name} {desc}"
|
||||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
|
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
|
||||||
self._attr_has_entity_name = False
|
self._attr_has_entity_name = False
|
||||||
self.program = program
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Call when entity is added to hass."""
|
"""Call when entity is added to hass."""
|
||||||
|
@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||||
@ -18,7 +19,7 @@ from .const import (
|
|||||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
from .utils import get_dict_from_home_connect_error
|
from .utils import get_dict_from_home_connect_error
|
||||||
|
|
||||||
@ -30,20 +31,28 @@ TIME_ENTITIES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
|
||||||
|
for description in TIME_ENTITIES
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect switch."""
|
"""Set up the Home Connect switch."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
[
|
_get_entities_for_appliance,
|
||||||
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
|
async_add_entities,
|
||||||
for description in TIME_ENTITIES
|
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,6 +68,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
|||||||
"""Determine the device class a homee node based on the node profile."""
|
"""Determine the device class a homee node based on the node profile."""
|
||||||
COVER_DEVICE_PROFILES = {
|
COVER_DEVICE_PROFILES = {
|
||||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||||
|
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool:
|
|||||||
return node.profile in [
|
return node.profile in [
|
||||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
|
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
|
||||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
|
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
|
||||||
|
NodeProfile.ENTRANCE_GATE_OPERATOR,
|
||||||
NodeProfile.GARAGE_DOOR_OPERATOR,
|
NodeProfile.GARAGE_DOOR_OPERATOR,
|
||||||
NodeProfile.SHUTTER_POSITION_SWITCH,
|
NodeProfile.SHUTTER_POSITION_SWITCH,
|
||||||
]
|
]
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
|
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
|
||||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
@ -137,7 +139,13 @@ class HomeeNodeEntity(Entity):
|
|||||||
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
||||||
"""Set an attribute value on the homee node."""
|
"""Set an attribute value on the homee node."""
|
||||||
homee = self._entry.runtime_data
|
homee = self._entry.runtime_data
|
||||||
await homee.set_value(attribute.node_id, attribute.id, value)
|
try:
|
||||||
|
await homee.set_value(attribute.node_id, attribute.id, value)
|
||||||
|
except ConnectionClosed as exception:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="connection_closed",
|
||||||
|
) from exception
|
||||||
|
|
||||||
def _on_node_updated(self, node: HomeeNode) -> None:
|
def _on_node_updated(self, node: HomeeNode) -> None:
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
|||||||
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
|
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
|
||||||
key="total_current",
|
key="total_current",
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
|
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
|
||||||
key="total_power",
|
key="total_power",
|
||||||
@ -252,7 +253,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
|
|||||||
],
|
],
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
translation_key="node_sensor_state",
|
translation_key="node_state",
|
||||||
value_fn=lambda node: get_name_for_enum(NodeState, node.state),
|
value_fn=lambda node: get_name_for_enum(NodeState, node.state),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -67,7 +67,23 @@
|
|||||||
"name": "Link quality"
|
"name": "Link quality"
|
||||||
},
|
},
|
||||||
"node_state": {
|
"node_state": {
|
||||||
"name": "Node state"
|
"name": "Node state",
|
||||||
|
"state": {
|
||||||
|
"available": "Available",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"update_in_progress": "Update in progress",
|
||||||
|
"waiting_for_attributes": "Waiting for attributes",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"user_interaction_required": "User interaction required",
|
||||||
|
"password_required": "Password required",
|
||||||
|
"host_unavailable": "Host unavailable",
|
||||||
|
"delete_in_progress": "Delete in progress",
|
||||||
|
"cosi_connected": "Cosi connected",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"waiting_for_wakeup": "Waiting for wakeup",
|
||||||
|
"remote_node_deleted": "Remote node deleted",
|
||||||
|
"firmware_update_in_progress": "Firmware update in progress"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"operating_hours": {
|
"operating_hours": {
|
||||||
"name": "Operating hours"
|
"name": "Operating hours"
|
||||||
@ -136,5 +152,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"connection_closed": {
|
||||||
|
"message": "Could not connect to Homee while setting attribute"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,6 +78,7 @@ from .const import (
|
|||||||
CONF_VIDEO_CODEC,
|
CONF_VIDEO_CODEC,
|
||||||
CONF_VIDEO_MAP,
|
CONF_VIDEO_MAP,
|
||||||
CONF_VIDEO_PACKET_SIZE,
|
CONF_VIDEO_PACKET_SIZE,
|
||||||
|
CONF_VIDEO_PROFILE_NAMES,
|
||||||
DEFAULT_AUDIO_CODEC,
|
DEFAULT_AUDIO_CODEC,
|
||||||
DEFAULT_AUDIO_MAP,
|
DEFAULT_AUDIO_MAP,
|
||||||
DEFAULT_AUDIO_PACKET_SIZE,
|
DEFAULT_AUDIO_PACKET_SIZE,
|
||||||
@ -90,6 +91,7 @@ from .const import (
|
|||||||
DEFAULT_VIDEO_CODEC,
|
DEFAULT_VIDEO_CODEC,
|
||||||
DEFAULT_VIDEO_MAP,
|
DEFAULT_VIDEO_MAP,
|
||||||
DEFAULT_VIDEO_PACKET_SIZE,
|
DEFAULT_VIDEO_PACKET_SIZE,
|
||||||
|
DEFAULT_VIDEO_PROFILE_NAMES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FEATURE_ON_OFF,
|
FEATURE_ON_OFF,
|
||||||
FEATURE_PLAY_PAUSE,
|
FEATURE_PLAY_PAUSE,
|
||||||
@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|||||||
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
|
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
|
||||||
VALID_VIDEO_CODECS
|
VALID_VIDEO_CODECS
|
||||||
),
|
),
|
||||||
|
vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
|
||||||
|
cv.string
|
||||||
|
],
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
|
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
|
||||||
): cv.positive_int,
|
): cv.positive_int,
|
||||||
|
@ -12,6 +12,6 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["homewizard_energy"],
|
"loggers": ["homewizard_energy"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["python-homewizard-energy==v8.3.0"],
|
"requirements": ["python-homewizard-energy==v8.3.2"],
|
||||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/imap",
|
"documentation": "https://www.home-assistant.io/integrations/imap",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioimaplib"],
|
"loggers": ["aioimaplib"],
|
||||||
"requirements": ["aioimaplib==2.0.0"]
|
"requirements": ["aioimaplib==2.0.1"]
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=self._discovered_devices[address], data={}
|
title=self._discovered_devices[address], data={}
|
||||||
)
|
)
|
||||||
|
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -198,14 +198,13 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
|
|||||||
|
|
||||||
# Handle ISY precision and rounding
|
# Handle ISY precision and rounding
|
||||||
value = convert_isy_value_to_hass(value, uom, self.target.prec)
|
value = convert_isy_value_to_hass(value, uom, self.target.prec)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
# Convert temperatures to Home Assistant's unit
|
# Convert temperatures to Home Assistant's unit
|
||||||
if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT):
|
if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT):
|
||||||
value = self.hass.config.units.temperature(value, uom)
|
value = self.hass.config.units.temperature(value, uom)
|
||||||
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
assert isinstance(value, (int, float))
|
assert isinstance(value, (int, float))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["jellyfin_apiclient_python"],
|
"loggers": ["jellyfin_apiclient_python"],
|
||||||
"requirements": ["jellyfin-apiclient-python==1.9.2"],
|
"requirements": ["jellyfin-apiclient-python==1.10.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"xknx==3.5.0",
|
"xknx==3.5.0",
|
||||||
"xknxproject==3.8.1",
|
"xknxproject==3.8.1",
|
||||||
"knx-frontend==2025.1.28.225404"
|
"knx-frontend==2025.1.30.194235"
|
||||||
],
|
],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
|
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["lacrosse_view"],
|
"loggers": ["lacrosse_view"],
|
||||||
"requirements": ["lacrosse-view==1.0.3"]
|
"requirements": ["lacrosse-view==1.0.4"]
|
||||||
}
|
}
|
||||||
|
@ -95,6 +95,9 @@
|
|||||||
"drink_stats_flushing": {
|
"drink_stats_flushing": {
|
||||||
"default": "mdi:chart-line"
|
"default": "mdi:chart-line"
|
||||||
},
|
},
|
||||||
|
"drink_stats_coffee_key": {
|
||||||
|
"default": "mdi:chart-scatter-plot"
|
||||||
|
},
|
||||||
"shot_timer": {
|
"shot_timer": {
|
||||||
"default": "mdi:timer"
|
"default": "mdi:timer"
|
||||||
},
|
},
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pylamarzocco.const import BoilerType, MachineModel
|
from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey
|
||||||
from pylamarzocco.devices.machine import LaMarzoccoMachine
|
from pylamarzocco.devices.machine import LaMarzoccoMachine
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@ -21,7 +21,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LaMarzoccoConfigEntry
|
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
|
||||||
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
|
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
|
||||||
|
|
||||||
# Coordinator is used to centralize the data updates
|
# Coordinator is used to centralize the data updates
|
||||||
@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription(
|
|||||||
value_fn: Callable[[LaMarzoccoMachine], float | int]
|
value_fn: Callable[[LaMarzoccoMachine], float | int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class LaMarzoccoKeySensorEntityDescription(
|
||||||
|
LaMarzoccoEntityDescription, SensorEntityDescription
|
||||||
|
):
|
||||||
|
"""Description of a keyed La Marzocco sensor."""
|
||||||
|
|
||||||
|
value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None]
|
||||||
|
|
||||||
|
|
||||||
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||||
LaMarzoccoSensorEntityDescription(
|
LaMarzoccoSensorEntityDescription(
|
||||||
key="shot_timer",
|
key="shot_timer",
|
||||||
@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
|||||||
LaMarzoccoSensorEntityDescription(
|
LaMarzoccoSensorEntityDescription(
|
||||||
key="drink_stats_coffee",
|
key="drink_stats_coffee",
|
||||||
translation_key="drink_stats_coffee",
|
translation_key="drink_stats_coffee",
|
||||||
native_unit_of_measurement="drinks",
|
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
value_fn=lambda device: device.statistics.total_coffee,
|
value_fn=lambda device: device.statistics.total_coffee,
|
||||||
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
|
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
|
||||||
@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
|||||||
LaMarzoccoSensorEntityDescription(
|
LaMarzoccoSensorEntityDescription(
|
||||||
key="drink_stats_flushing",
|
key="drink_stats_flushing",
|
||||||
translation_key="drink_stats_flushing",
|
translation_key="drink_stats_flushing",
|
||||||
native_unit_of_measurement="drinks",
|
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
value_fn=lambda device: device.statistics.total_flushes,
|
value_fn=lambda device: device.statistics.total_flushes,
|
||||||
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
|
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
|
||||||
@ -96,6 +103,18 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = (
|
||||||
|
LaMarzoccoKeySensorEntityDescription(
|
||||||
|
key="drink_stats_coffee_key",
|
||||||
|
translation_key="drink_stats_coffee_key",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
value_fn=lambda device, key: device.statistics.drink_stats.get(key),
|
||||||
|
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||||
LaMarzoccoSensorEntityDescription(
|
LaMarzoccoSensorEntityDescription(
|
||||||
key="scale_battery",
|
key="scale_battery",
|
||||||
@ -120,6 +139,8 @@ async def async_setup_entry(
|
|||||||
"""Set up sensor entities."""
|
"""Set up sensor entities."""
|
||||||
config_coordinator = entry.runtime_data.config_coordinator
|
config_coordinator = entry.runtime_data.config_coordinator
|
||||||
|
|
||||||
|
entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = []
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
LaMarzoccoSensorEntity(config_coordinator, description)
|
LaMarzoccoSensorEntity(config_coordinator, description)
|
||||||
for description in ENTITIES
|
for description in ENTITIES
|
||||||
@ -142,6 +163,14 @@ async def async_setup_entry(
|
|||||||
if description.supported_fn(statistics_coordinator)
|
if description.supported_fn(statistics_coordinator)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)]
|
||||||
|
if num_keys > 0:
|
||||||
|
entities.extend(
|
||||||
|
LaMarzoccoKeySensorEntity(statistics_coordinator, description, key)
|
||||||
|
for description in KEY_STATISTIC_ENTITIES
|
||||||
|
for key in range(1, num_keys + 1)
|
||||||
|
)
|
||||||
|
|
||||||
def _async_add_new_scale() -> None:
|
def _async_add_new_scale() -> None:
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
LaMarzoccoScaleSensorEntity(config_coordinator, description)
|
LaMarzoccoScaleSensorEntity(config_coordinator, description)
|
||||||
@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
|
|||||||
entity_description: LaMarzoccoSensorEntityDescription
|
entity_description: LaMarzoccoSensorEntityDescription
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> int | float:
|
def native_value(self) -> int | float | None:
|
||||||
"""State of the sensor."""
|
"""State of the sensor."""
|
||||||
return self.entity_description.value_fn(self.coordinator.device)
|
return self.entity_description.value_fn(self.coordinator.device)
|
||||||
|
|
||||||
|
|
||||||
|
class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity):
|
||||||
|
"""Sensor for a La Marzocco key."""
|
||||||
|
|
||||||
|
entity_description: LaMarzoccoKeySensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: LaMarzoccoUpdateCoordinator,
|
||||||
|
description: LaMarzoccoKeySensorEntityDescription,
|
||||||
|
key: int,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator, description)
|
||||||
|
self.key = key
|
||||||
|
self._attr_translation_placeholders = {"key": str(key)}
|
||||||
|
self._attr_unique_id = f"{super()._attr_unique_id}_key{key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int | None:
|
||||||
|
"""State of the sensor."""
|
||||||
|
return self.entity_description.value_fn(
|
||||||
|
self.coordinator.device, PhysicalKey(self.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
|
class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
|
||||||
"""Sensor for a La Marzocco scale."""
|
"""Sensor for a La Marzocco scale."""
|
||||||
|
|
||||||
|
@ -175,10 +175,16 @@
|
|||||||
"name": "Current steam temperature"
|
"name": "Current steam temperature"
|
||||||
},
|
},
|
||||||
"drink_stats_coffee": {
|
"drink_stats_coffee": {
|
||||||
"name": "Total coffees made"
|
"name": "Total coffees made",
|
||||||
|
"unit_of_measurement": "coffees"
|
||||||
|
},
|
||||||
|
"drink_stats_coffee_key": {
|
||||||
|
"name": "Coffees made Key {key}",
|
||||||
|
"unit_of_measurement": "coffees"
|
||||||
},
|
},
|
||||||
"drink_stats_flushing": {
|
"drink_stats_flushing": {
|
||||||
"name": "Total flushes made"
|
"name": "Total flushes made",
|
||||||
|
"unit_of_measurement": "flushes"
|
||||||
},
|
},
|
||||||
"shot_timer": {
|
"shot_timer": {
|
||||||
"name": "Shot timer"
|
"name": "Shot timer"
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
|
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"]
|
"requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"]
|
||||||
}
|
}
|
||||||
|
@ -35,5 +35,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"]
|
"requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"]
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .coordinator import LetPotDeviceCoordinator
|
from .coordinator import LetPotDeviceCoordinator
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.TIME]
|
PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
|
||||||
|
|
||||||
type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]
|
type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]
|
||||||
|
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
"""Base class for LetPot entities."""
|
"""Base class for LetPot entities."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any, Concatenate
|
||||||
|
|
||||||
|
from letpot.exceptions import LetPotConnectionException, LetPotException
|
||||||
|
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
|
|||||||
model_id=coordinator.device_client.device_model_code,
|
model_id=coordinator.device_client.device_model_code,
|
||||||
serial_number=coordinator.device.serial_number,
|
serial_number=coordinator.device.serial_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exception_handler[_EntityT: LetPotEntity, **_P](
|
||||||
|
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||||
|
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||||
|
"""Decorate the function to catch LetPot exceptions and raise them correctly."""
|
||||||
|
|
||||||
|
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||||
|
try:
|
||||||
|
await func(self, *args, **kwargs)
|
||||||
|
except LetPotConnectionException as exception:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="communication_error",
|
||||||
|
translation_placeholders={"exception": str(exception)},
|
||||||
|
) from exception
|
||||||
|
except LetPotException as exception:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unknown_error",
|
||||||
|
translation_placeholders={"exception": str(exception)},
|
||||||
|
) from exception
|
||||||
|
|
||||||
|
return handler
|
||||||
|
24
homeassistant/components/letpot/icons.json
Normal file
24
homeassistant/components/letpot/icons.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"switch": {
|
||||||
|
"alarm_sound": {
|
||||||
|
"default": "mdi:bell-ring",
|
||||||
|
"state": {
|
||||||
|
"off": "mdi:bell-off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auto_mode": {
|
||||||
|
"default": "mdi:water-pump",
|
||||||
|
"state": {
|
||||||
|
"off": "mdi:water-pump-off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pump_cycling": {
|
||||||
|
"default": "mdi:pump",
|
||||||
|
"state": {
|
||||||
|
"off": "mdi:pump-off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["letpot==0.3.0"]
|
"requirements": ["letpot==0.4.0"]
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ rules:
|
|||||||
unique-config-entry: done
|
unique-config-entry: done
|
||||||
|
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: todo
|
action-exceptions: done
|
||||||
config-entry-unloading:
|
config-entry-unloading:
|
||||||
status: done
|
status: done
|
||||||
comment: |
|
comment: |
|
||||||
@ -63,8 +63,8 @@ rules:
|
|||||||
entity-device-class: todo
|
entity-device-class: todo
|
||||||
entity-disabled-by-default: todo
|
entity-disabled-by-default: todo
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: todo
|
exception-translations: done
|
||||||
icon-translations: todo
|
icon-translations: done
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: todo
|
||||||
repair-issues: todo
|
repair-issues: todo
|
||||||
stale-devices: todo
|
stale-devices: todo
|
||||||
|
@ -32,6 +32,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"switch": {
|
||||||
|
"alarm_sound": {
|
||||||
|
"name": "Alarm sound"
|
||||||
|
},
|
||||||
|
"auto_mode": {
|
||||||
|
"name": "Auto mode"
|
||||||
|
},
|
||||||
|
"power": {
|
||||||
|
"name": "Power"
|
||||||
|
},
|
||||||
|
"pump_cycling": {
|
||||||
|
"name": "Pump cycling"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"light_schedule_end": {
|
"light_schedule_end": {
|
||||||
"name": "Light off"
|
"name": "Light off"
|
||||||
@ -40,5 +54,13 @@
|
|||||||
"name": "Light on"
|
"name": "Light on"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"communication_error": {
|
||||||
|
"message": "An error occurred while communicating with the LetPot device: {exception}"
|
||||||
|
},
|
||||||
|
"unknown_error": {
|
||||||
|
"message": "An unknown error occurred while communicating with the LetPot device: {exception}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
119
homeassistant/components/letpot/switch.py
Normal file
119
homeassistant/components/letpot/switch.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Support for LetPot switch entities."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from letpot.deviceclient import LetPotDeviceClient
|
||||||
|
from letpot.models import DeviceFeature, LetPotDeviceStatus
|
||||||
|
|
||||||
|
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||||
|
from homeassistant.const import EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from . import LetPotConfigEntry
|
||||||
|
from .coordinator import LetPotDeviceCoordinator
|
||||||
|
from .entity import LetPotEntity, exception_handler
|
||||||
|
|
||||||
|
# Each change pushes a 'full' device status with the change. The library will cache
|
||||||
|
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class LetPotSwitchEntityDescription(SwitchEntityDescription):
|
||||||
|
"""Describes a LetPot switch entity."""
|
||||||
|
|
||||||
|
value_fn: Callable[[LetPotDeviceStatus], bool | None]
|
||||||
|
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
|
||||||
|
|
||||||
|
|
||||||
|
BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
|
||||||
|
LetPotSwitchEntityDescription(
|
||||||
|
key="power",
|
||||||
|
translation_key="power",
|
||||||
|
value_fn=lambda status: status.system_on,
|
||||||
|
set_value_fn=lambda device_client, value: device_client.set_power(value),
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
),
|
||||||
|
LetPotSwitchEntityDescription(
|
||||||
|
key="pump_cycling",
|
||||||
|
translation_key="pump_cycling",
|
||||||
|
value_fn=lambda status: status.pump_mode == 1,
|
||||||
|
set_value_fn=lambda device_client, value: device_client.set_pump_mode(value),
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
|
||||||
|
key="alarm_sound",
|
||||||
|
translation_key="alarm_sound",
|
||||||
|
value_fn=lambda status: status.system_sound,
|
||||||
|
set_value_fn=lambda device_client, value: device_client.set_sound(value),
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
)
|
||||||
|
AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
|
||||||
|
key="auto_mode",
|
||||||
|
translation_key="auto_mode",
|
||||||
|
value_fn=lambda status: status.water_mode == 1,
|
||||||
|
set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: LetPotConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up LetPot switch entities based on a config entry and device status/features."""
|
||||||
|
coordinators = entry.runtime_data
|
||||||
|
entities: list[SwitchEntity] = [
|
||||||
|
LetPotSwitchEntity(coordinator, description)
|
||||||
|
for description in BASE_SWITCHES
|
||||||
|
for coordinator in coordinators
|
||||||
|
]
|
||||||
|
entities.extend(
|
||||||
|
LetPotSwitchEntity(coordinator, ALARM_SWITCH)
|
||||||
|
for coordinator in coordinators
|
||||||
|
if coordinator.data.system_sound is not None
|
||||||
|
)
|
||||||
|
entities.extend(
|
||||||
|
LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH)
|
||||||
|
for coordinator in coordinators
|
||||||
|
if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class LetPotSwitchEntity(LetPotEntity, SwitchEntity):
|
||||||
|
"""Defines a LetPot switch entity."""
|
||||||
|
|
||||||
|
entity_description: LetPotSwitchEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: LetPotDeviceCoordinator,
|
||||||
|
description: LetPotSwitchEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize LetPot switch entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the entity is on."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the entity on."""
|
||||||
|
await self.entity_description.set_value_fn(self.coordinator.device_client, True)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the entity off."""
|
||||||
|
await self.entity_description.set_value_fn(
|
||||||
|
self.coordinator.device_client, False
|
||||||
|
)
|
@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from . import LetPotConfigEntry
|
from . import LetPotConfigEntry
|
||||||
from .coordinator import LetPotDeviceCoordinator
|
from .coordinator import LetPotDeviceCoordinator
|
||||||
from .entity import LetPotEntity
|
from .entity import LetPotEntity, exception_handler
|
||||||
|
|
||||||
# Each change pushes a 'full' device status with the change. The library will cache
|
# Each change pushes a 'full' device status with the change. The library will cache
|
||||||
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
|
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
|
||||||
@ -86,6 +86,7 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity):
|
|||||||
"""Return the time."""
|
"""Return the time."""
|
||||||
return self.entity_description.value_fn(self.coordinator.data)
|
return self.entity_description.value_fn(self.coordinator.data)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_set_value(self, value: time) -> None:
|
async def async_set_value(self, value: time) -> None:
|
||||||
"""Set the time."""
|
"""Set the time."""
|
||||||
await self.entity_description.set_value_fn(
|
await self.entity_description.set_value_fn(
|
||||||
|
@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
|
|||||||
saturation = int(saturation / 100 * 65535)
|
saturation = int(saturation / 100 * 65535)
|
||||||
kelvin = 3500
|
kelvin = 3500
|
||||||
|
|
||||||
if _ATTR_COLOR_TEMP in kwargs:
|
if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs:
|
||||||
# added in 2025.1, can be removed in 2026.1
|
# added in 2025.1, can be removed in 2026.1
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"
|
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"
|
||||||
|
1
homeassistant/components/linx/__init__.py
Normal file
1
homeassistant/components/linx/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Virtual integration: Linx."""
|
6
homeassistant/components/linx/manifest.json
Normal file
6
homeassistant/components/linx/manifest.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"domain": "linx",
|
||||||
|
"name": "Linx",
|
||||||
|
"integration_type": "virtual",
|
||||||
|
"supported_by": "motion_blinds"
|
||||||
|
}
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntry
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
@ -46,6 +48,9 @@ async def async_remove_config_entry_device(
|
|||||||
identifier
|
identifier
|
||||||
for identifier in device_entry.identifiers
|
for identifier in device_entry.identifiers
|
||||||
if identifier[0] == DOMAIN
|
if identifier[0] == DOMAIN
|
||||||
for robot in entry.runtime_data.account.robots
|
for _id in itertools.chain(
|
||||||
if robot.serial == identifier[1]
|
(robot.serial for robot in entry.runtime_data.account.robots),
|
||||||
|
(pet.id for pet in entry.runtime_data.account.pets),
|
||||||
|
)
|
||||||
|
if _id == identifier[1]
|
||||||
)
|
)
|
||||||
|
@ -18,16 +18,16 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry
|
from .coordinator import LitterRobotConfigEntry
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotBinarySensorEntityDescription(
|
class RobotBinarySensorEntityDescription(
|
||||||
BinarySensorEntityDescription, Generic[_RobotT]
|
BinarySensorEntityDescription, Generic[_WhiskerEntityT]
|
||||||
):
|
):
|
||||||
"""A class that describes robot binary sensor entities."""
|
"""A class that describes robot binary sensor entities."""
|
||||||
|
|
||||||
is_on_fn: Callable[[_RobotT], bool]
|
is_on_fn: Callable[[_WhiskerEntityT], bool]
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
|
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
|
||||||
@ -78,10 +78,12 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity):
|
class LitterRobotBinarySensorEntity(
|
||||||
|
LitterRobotEntity[_WhiskerEntityT], BinarySensorEntity
|
||||||
|
):
|
||||||
"""Litter-Robot binary sensor entity."""
|
"""Litter-Robot binary sensor entity."""
|
||||||
|
|
||||||
entity_description: RobotBinarySensorEntityDescription[_RobotT]
|
entity_description: RobotBinarySensorEntityDescription[_WhiskerEntityT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
|
@ -14,14 +14,14 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry
|
from .coordinator import LitterRobotConfigEntry
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]):
|
class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]):
|
||||||
"""A class that describes robot button entities."""
|
"""A class that describes robot button entities."""
|
||||||
|
|
||||||
press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]]
|
press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]]
|
||||||
|
|
||||||
|
|
||||||
ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = {
|
ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = {
|
||||||
@ -62,10 +62,10 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity):
|
class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):
|
||||||
"""Litter-Robot button entity."""
|
"""Litter-Robot button entity."""
|
||||||
|
|
||||||
entity_description: RobotButtonEntityDescription[_RobotT]
|
entity_description: RobotButtonEntityDescription[_WhiskerEntityT]
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
|
@ -47,6 +47,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Update all device states from the Litter-Robot API."""
|
"""Update all device states from the Litter-Robot API."""
|
||||||
await self.account.refresh_robots()
|
await self.account.refresh_robots()
|
||||||
|
await self.account.load_pets()
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
"""Set up the coordinator."""
|
"""Set up the coordinator."""
|
||||||
@ -56,6 +57,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
password=self.config_entry.data[CONF_PASSWORD],
|
password=self.config_entry.data[CONF_PASSWORD],
|
||||||
load_robots=True,
|
load_robots=True,
|
||||||
subscribe_for_updates=True,
|
subscribe_for_updates=True,
|
||||||
|
load_pets=True,
|
||||||
)
|
)
|
||||||
except LitterRobotLoginException as ex:
|
except LitterRobotLoginException as ex:
|
||||||
raise ConfigEntryAuthFailed("Invalid credentials") from ex
|
raise ConfigEntryAuthFailed("Invalid credentials") from ex
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from pylitterbot import Robot
|
from pylitterbot import Pet, Robot
|
||||||
from pylitterbot.robot import EVENT_UPDATE
|
from pylitterbot.robot import EVENT_UPDATE
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
@ -14,11 +14,31 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LitterRobotDataUpdateCoordinator
|
from .coordinator import LitterRobotDataUpdateCoordinator
|
||||||
|
|
||||||
_RobotT = TypeVar("_RobotT", bound=Robot)
|
_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo:
|
||||||
|
"""Get device info for a robot or pet."""
|
||||||
|
if isinstance(whisker_entity, Robot):
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, whisker_entity.serial)},
|
||||||
|
manufacturer="Whisker",
|
||||||
|
model=whisker_entity.model,
|
||||||
|
name=whisker_entity.name,
|
||||||
|
serial_number=whisker_entity.serial,
|
||||||
|
sw_version=getattr(whisker_entity, "firmware", None),
|
||||||
|
)
|
||||||
|
breed = ", ".join(breed for breed in whisker_entity.breeds or [])
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, whisker_entity.id)},
|
||||||
|
manufacturer="Whisker",
|
||||||
|
model=f"{breed} {whisker_entity.pet_type}".strip().capitalize(),
|
||||||
|
name=whisker_entity.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LitterRobotEntity(
|
class LitterRobotEntity(
|
||||||
CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT]
|
CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_WhiskerEntityT]
|
||||||
):
|
):
|
||||||
"""Generic Litter-Robot entity representing common data and methods."""
|
"""Generic Litter-Robot entity representing common data and methods."""
|
||||||
|
|
||||||
@ -26,7 +46,7 @@ class LitterRobotEntity(
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
robot: _RobotT,
|
robot: _WhiskerEntityT,
|
||||||
coordinator: LitterRobotDataUpdateCoordinator,
|
coordinator: LitterRobotDataUpdateCoordinator,
|
||||||
description: EntityDescription,
|
description: EntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -34,15 +54,9 @@ class LitterRobotEntity(
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.robot = robot
|
self.robot = robot
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._attr_unique_id = f"{robot.serial}-{description.key}"
|
_id = robot.serial if isinstance(robot, Robot) else robot.id
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_unique_id = f"{_id}-{description.key}"
|
||||||
identifiers={(DOMAIN, robot.serial)},
|
self._attr_device_info = get_device_info(robot)
|
||||||
manufacturer="Whisker",
|
|
||||||
model=robot.model,
|
|
||||||
name=robot.name,
|
|
||||||
serial_number=robot.serial,
|
|
||||||
sw_version=getattr(robot, "firmware", None),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Set up a listener for the entity."""
|
"""Set up a listener for the entity."""
|
||||||
|
@ -15,21 +15,21 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
|
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
|
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotSelectEntityDescription(
|
class RobotSelectEntityDescription(
|
||||||
SelectEntityDescription, Generic[_RobotT, _CastTypeT]
|
SelectEntityDescription, Generic[_WhiskerEntityT, _CastTypeT]
|
||||||
):
|
):
|
||||||
"""A class that describes robot select entities."""
|
"""A class that describes robot select entities."""
|
||||||
|
|
||||||
entity_category: EntityCategory = EntityCategory.CONFIG
|
entity_category: EntityCategory = EntityCategory.CONFIG
|
||||||
current_fn: Callable[[_RobotT], _CastTypeT | None]
|
current_fn: Callable[[_WhiskerEntityT], _CastTypeT | None]
|
||||||
options_fn: Callable[[_RobotT], list[_CastTypeT]]
|
options_fn: Callable[[_WhiskerEntityT], list[_CastTypeT]]
|
||||||
select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]]
|
select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]]
|
||||||
|
|
||||||
|
|
||||||
ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
|
ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
|
||||||
@ -83,17 +83,19 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LitterRobotSelectEntity(
|
class LitterRobotSelectEntity(
|
||||||
LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT]
|
LitterRobotEntity[_WhiskerEntityT],
|
||||||
|
SelectEntity,
|
||||||
|
Generic[_WhiskerEntityT, _CastTypeT],
|
||||||
):
|
):
|
||||||
"""Litter-Robot Select."""
|
"""Litter-Robot Select."""
|
||||||
|
|
||||||
entity_description: RobotSelectEntityDescription[_RobotT, _CastTypeT]
|
entity_description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
robot: _RobotT,
|
robot: _WhiskerEntityT,
|
||||||
coordinator: LitterRobotDataUpdateCoordinator,
|
coordinator: LitterRobotDataUpdateCoordinator,
|
||||||
description: RobotSelectEntityDescription[_RobotT, _CastTypeT],
|
description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a Litter-Robot select entity."""
|
"""Initialize a Litter-Robot select entity."""
|
||||||
super().__init__(robot, coordinator, description)
|
super().__init__(robot, coordinator, description)
|
||||||
|
@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generic
|
from typing import Any, Generic
|
||||||
|
|
||||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot
|
from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Pet, Robot
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry
|
from .coordinator import LitterRobotConfigEntry
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
|
|
||||||
def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str:
|
def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str:
|
||||||
@ -35,11 +35,11 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]):
|
class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]):
|
||||||
"""A class that describes robot sensor entities."""
|
"""A class that describes robot sensor entities."""
|
||||||
|
|
||||||
icon_fn: Callable[[Any], str | None] = lambda _: None
|
icon_fn: Callable[[Any], str | None] = lambda _: None
|
||||||
value_fn: Callable[[_RobotT], float | datetime | str | None]
|
value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None]
|
||||||
|
|
||||||
|
|
||||||
ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
|
ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
|
||||||
@ -146,6 +146,16 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PET_SENSORS: list[RobotSensorEntityDescription] = [
|
||||||
|
RobotSensorEntityDescription[Pet](
|
||||||
|
key="weight",
|
||||||
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
|
native_unit_of_measurement=UnitOfMass.POUNDS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda pet: pet.weight,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -154,7 +164,7 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Litter-Robot sensors using config entry."""
|
"""Set up Litter-Robot sensors using config entry."""
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(
|
entities: list[LitterRobotSensorEntity] = [
|
||||||
LitterRobotSensorEntity(
|
LitterRobotSensorEntity(
|
||||||
robot=robot, coordinator=coordinator, description=description
|
robot=robot, coordinator=coordinator, description=description
|
||||||
)
|
)
|
||||||
@ -162,13 +172,21 @@ async def async_setup_entry(
|
|||||||
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
|
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
|
||||||
if isinstance(robot, robot_type)
|
if isinstance(robot, robot_type)
|
||||||
for description in entity_descriptions
|
for description in entity_descriptions
|
||||||
|
]
|
||||||
|
entities.extend(
|
||||||
|
LitterRobotSensorEntity(
|
||||||
|
robot=pet, coordinator=coordinator, description=description
|
||||||
|
)
|
||||||
|
for pet in coordinator.account.pets
|
||||||
|
for description in PET_SENSORS
|
||||||
)
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity):
|
class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity):
|
||||||
"""Litter-Robot sensor entity."""
|
"""Litter-Robot sensor entity."""
|
||||||
|
|
||||||
entity_description: RobotSensorEntityDescription[_RobotT]
|
entity_description: RobotSensorEntityDescription[_WhiskerEntityT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> float | datetime | str | None:
|
def native_value(self) -> float | datetime | str | None:
|
||||||
|
@ -14,16 +14,16 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry
|
from .coordinator import LitterRobotConfigEntry
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]):
|
class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]):
|
||||||
"""A class that describes robot switch entities."""
|
"""A class that describes robot switch entities."""
|
||||||
|
|
||||||
entity_category: EntityCategory = EntityCategory.CONFIG
|
entity_category: EntityCategory = EntityCategory.CONFIG
|
||||||
set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]]
|
set_fn: Callable[[_WhiskerEntityT, bool], Coroutine[Any, Any, bool]]
|
||||||
value_fn: Callable[[_RobotT], bool]
|
value_fn: Callable[[_WhiskerEntityT], bool]
|
||||||
|
|
||||||
|
|
||||||
ROBOT_SWITCHES = [
|
ROBOT_SWITCHES = [
|
||||||
@ -57,10 +57,10 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity):
|
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
|
||||||
"""Litter-Robot switch entity."""
|
"""Litter-Robot switch entity."""
|
||||||
|
|
||||||
entity_description: RobotSwitchEntityDescription[_RobotT]
|
entity_description: RobotSwitchEntityDescription[_WhiskerEntityT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
|
@ -16,15 +16,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .coordinator import LitterRobotConfigEntry
|
from .coordinator import LitterRobotConfigEntry
|
||||||
from .entity import LitterRobotEntity, _RobotT
|
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]):
|
class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]):
|
||||||
"""A class that describes robot time entities."""
|
"""A class that describes robot time entities."""
|
||||||
|
|
||||||
value_fn: Callable[[_RobotT], time | None]
|
value_fn: Callable[[_WhiskerEntityT], time | None]
|
||||||
set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]]
|
set_fn: Callable[[_WhiskerEntityT, time], Coroutine[Any, Any, bool]]
|
||||||
|
|
||||||
|
|
||||||
def _as_local_time(start: datetime | None) -> time | None:
|
def _as_local_time(start: datetime | None) -> time | None:
|
||||||
@ -64,10 +64,10 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LitterRobotTimeEntity(LitterRobotEntity[_RobotT], TimeEntity):
|
class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):
|
||||||
"""Litter-Robot time entity."""
|
"""Litter-Robot time entity."""
|
||||||
|
|
||||||
entity_description: RobotTimeEntityDescription[_RobotT]
|
entity_description: RobotTimeEntityDescription[_WhiskerEntityT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> time | None:
|
def native_value(self) -> time | None:
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user