mirror of
https://github.com/home-assistant/core.git
synced 2026-05-19 23:35:20 +02:00
Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea33a8040f | |||
| bcec29763f | |||
| 27ad459ae0 | |||
| 9c933ef01f | |||
| 2011e64390 | |||
| ffc2b0a8cf | |||
| 549069e22c | |||
| 57e4270b7b | |||
| 38e4e18f60 | |||
| 7f2a32d4eb | |||
| d46e0e132b | |||
| 828f0f8b26 | |||
| 849a25e3cc | |||
| 3cb579d585 | |||
| 381bd489d8 | |||
| f5b785acd5 | |||
| 648dce2fa3 | |||
| d14a0e0191 | |||
| 9caf46c68b | |||
| e89ae021d8 | |||
| 36156d9c54 | |||
| 3e0628cec2 | |||
| 8bd51a7fd1 | |||
| 5b29d6bbdf | |||
| 2c2ac4b669 | |||
| 35097602d7 | |||
| e5fe243a86 | |||
| fd10fa1fba | |||
| 087a938a7d | |||
| c058561162 | |||
| b89b248b4c | |||
| cd94685b7d | |||
| 0acfb81d50 | |||
| 7d06aec8da | |||
| ee4325a927 | |||
| c7aadcdd20 | |||
| 8256401f7f | |||
| ab187f39c2 | |||
| 1cb278966c | |||
| b522bd5ef2 | |||
| a6e1d96852 | |||
| 3d74d02704 | |||
| db45f46c8a | |||
| 4f938d032d | |||
| e1f15dac39 | |||
| 41e261096a | |||
| f6aa4aa788 | |||
| 7d7767c93a | |||
| 5e883cfb12 | |||
| e2cc51f21d | |||
| 816977dd75 | |||
| a81e83cb28 | |||
| c476500c49 | |||
| f65fa38429 | |||
| 66641356cc | |||
| 37ae476c67 | |||
| 5ec9c4e6e3 | |||
| 80eb4fb2f6 | |||
| 9e3a78b7ef | |||
| c08c402409 | |||
| d42d270fb2 | |||
| 9068a09620 | |||
| 1ef07544d5 | |||
| ed4a23d104 | |||
| 0729b3a2f1 | |||
| c9356868f7 | |||
| 1753baf186 | |||
| 8421ca7802 | |||
| 124931b2ee | |||
| c27a67db82 | |||
| 3ae9ea3f19 | |||
| e35f7b12f1 | |||
| 1a1e9e9f57 | |||
| 254f766357 | |||
| 7df0016fab | |||
| 57f89dd606 | |||
| 92bb1f2551 | |||
| f680e992ff | |||
| f08d1e547f | |||
| 9e022ad75e | |||
| 14ff04200e | |||
| 5e4ce46dae | |||
| 155fc134b6 | |||
| 25f64a2f36 | |||
| dcbdce4b2b | |||
| 50047f0a4e | |||
| 21b1122f83 | |||
| 09104fca4d | |||
| ad4e5459b1 | |||
| 334d5f09fb | |||
| 9f3d890e91 | |||
| eae9f4f925 | |||
| 5e50c723a7 | |||
| f761f7628a | |||
| 26d71fcdba | |||
| e4359e74c6 | |||
| 5e30e6cb91 | |||
| bc07030304 | |||
| 25ba2437dd | |||
| cfc7cfcf37 | |||
| 74288a3bc8 | |||
| b2fe17c6d4 | |||
| 611f86cf8c | |||
| 23a8442abe | |||
| f3ad6bd9b6 | |||
| 023dd9d523 | |||
| f7d132b043 | |||
| bb17f34bae | |||
| d22dd68119 | |||
| 4122af1d33 | |||
| 87fd45d4ab | |||
| 1c35aff510 | |||
| ab6ac94af9 | |||
| d33f73fce2 | |||
| fca6dc264f | |||
| 5287f4de81 | |||
| ccc1f01ff6 | |||
| 531f1f1964 | |||
| 72dc2b15d5 | |||
| cf2ef4cec1 | |||
| 28994152ae | |||
| ad881d892b | |||
| 87e641bf59 | |||
| 6ecaca753d | |||
| 017cd0bf45 | |||
| 1920edd712 | |||
| 2dca78efbb | |||
| e0179a7d45 | |||
| d393d5fdbb | |||
| a34264f345 | |||
| 73c9d99abf | |||
| ec5991bc68 | |||
| 87aecf0ed9 | |||
| 0b2ce73eac | |||
| 22828568e2 | |||
| 5a4c837328 | |||
| cd73824e3e | |||
| 32121a073c | |||
| c6c622797d | |||
| 193b32218f | |||
| e6702d2392 | |||
| 19b3b6cb28 | |||
| a2220cc2e6 | |||
| 18a89d5815 | |||
| 6eeec948a8 | |||
| 0e09a47476 | |||
| f0a636949a | |||
| d15baf9f9f | |||
| 4f27058a68 | |||
| 058e1ede10 | |||
| d23321cf54 | |||
| eb20292683 | |||
| 12f913e737 | |||
| 7e405d4ddb | |||
| 2829cc1248 | |||
| 8881919efd | |||
| a00f61f7be | |||
| c37b0a8f1d | |||
| c75b34a911 | |||
| cbe2fbdc34 | |||
| c2bc4a990e | |||
| 49baa65f61 | |||
| 24a7ebd2bb | |||
| a4b9efa1b1 | |||
| 15544769b6 | |||
| 3307132441 | |||
| da255af8de | |||
| a7e879714b | |||
| 8aaf5756e0 | |||
| ce5f06b1e5 | |||
| e42ca06173 | |||
| 2807f057de | |||
| 283d0d16c0 | |||
| 84959a0077 | |||
| e012196af8 | |||
| 5d43938f0d | |||
| cbdc8e3800 | |||
| 1b5bbda6b0 | |||
| 57083d877e | |||
| 3045f67ae5 | |||
| 6f31057d30 | |||
| 511ffdc03c | |||
| 59fe6da47c | |||
| e1cdc1af1c | |||
| f6e2b962fd | |||
| fe0ce9bc6d | |||
| b083919031 | |||
| ef2e699d2c | |||
| 71df8ffe6e | |||
| 98604f09fc | |||
| b97b04661e | |||
| 828037de1f | |||
| 659504c91f | |||
| 434ac421d1 | |||
| de849b920a | |||
| e387d4834f | |||
| 39ed877a17 | |||
| 13d05a338b | |||
| cb2095bcbe | |||
| 6de630ef3e | |||
| a02359b25d |
@@ -21,7 +21,7 @@ body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Task description
|
||||
label: Description
|
||||
description: |
|
||||
Provide a clear and detailed description of the task that needs to be accomplished.
|
||||
|
||||
@@ -43,9 +43,11 @@ body:
|
||||
|
||||
Include links to related issues, research, prototypes, roadmap opportunities etc.
|
||||
placeholder: |
|
||||
- Roadmap opportunity: [links]
|
||||
- Roadmap opportunity: [link]
|
||||
- Epic: [link]
|
||||
- Feature request: [link]
|
||||
- Technical design documents: [link]
|
||||
- Prototype/mockup: [link]
|
||||
- Dependencies: [links]
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -535,6 +535,7 @@ homeassistant.components.unifiprotect.*
|
||||
homeassistant.components.upcloud.*
|
||||
homeassistant.components.update.*
|
||||
homeassistant.components.uptime.*
|
||||
homeassistant.components.uptime_kuma.*
|
||||
homeassistant.components.uptimerobot.*
|
||||
homeassistant.components.usb.*
|
||||
homeassistant.components.uvc.*
|
||||
|
||||
Generated
+4
-2
@@ -1658,6 +1658,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/upnp/ @StevenLooman
|
||||
/homeassistant/components/uptime/ @frenck
|
||||
/tests/components/uptime/ @frenck
|
||||
/homeassistant/components/uptime_kuma/ @tr4nt0r
|
||||
/tests/components/uptime_kuma/ @tr4nt0r
|
||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/homeassistant/components/usb/ @bdraco
|
||||
@@ -1756,8 +1758,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
/homeassistant/components/wiz/ @sbidy
|
||||
/tests/components/wiz/ @sbidy
|
||||
/homeassistant/components/wiz/ @sbidy @arturpragacz
|
||||
/tests/components/wiz/ @sbidy @arturpragacz
|
||||
/homeassistant/components/wled/ @frenck
|
||||
/tests/components/wled/ @frenck
|
||||
/homeassistant/components/wmspro/ @mback2k
|
||||
|
||||
@@ -332,6 +332,9 @@ async def async_setup_hass(
|
||||
if not is_virtual_env():
|
||||
await async_mount_local_lib_path(runtime_config.config_dir)
|
||||
|
||||
if hass.config.safe_mode:
|
||||
_LOGGER.info("Starting in safe mode")
|
||||
|
||||
basic_setup_success = (
|
||||
await async_from_config_dict(config_dict, hass) is not None
|
||||
)
|
||||
@@ -384,8 +387,6 @@ async def async_setup_hass(
|
||||
{"recovery_mode": {}, "http": http_conf},
|
||||
hass,
|
||||
)
|
||||
elif hass.config.safe_mode:
|
||||
_LOGGER.info("Starting in safe mode")
|
||||
|
||||
if runtime_config.open_ui:
|
||||
hass.add_job(open_hass_ui, hass)
|
||||
@@ -870,9 +871,9 @@ async def _async_set_up_integrations(
|
||||
domains = set(integrations) & all_domains
|
||||
|
||||
_LOGGER.info(
|
||||
"Domains to be set up: %s | %s",
|
||||
domains,
|
||||
all_domains - domains,
|
||||
"Domains to be set up: %s\nDependencies: %s",
|
||||
domains or "{}",
|
||||
(all_domains - domains) or "{}",
|
||||
)
|
||||
|
||||
async_set_domains_to_be_loaded(hass, all_domains)
|
||||
@@ -913,12 +914,13 @@ async def _async_set_up_integrations(
|
||||
stage_all_domains = stage_domains | stage_dep_domains
|
||||
|
||||
_LOGGER.info(
|
||||
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
|
||||
"Setting up stage %s: %s; already set up: %s\n"
|
||||
"Dependencies: %s; already set up: %s",
|
||||
name,
|
||||
stage_domains,
|
||||
stage_domains_unfiltered - stage_domains,
|
||||
stage_dep_domains,
|
||||
stage_dep_domains_unfiltered - stage_dep_domains,
|
||||
(stage_domains_unfiltered - stage_domains) or "{}",
|
||||
stage_dep_domains or "{}",
|
||||
(stage_dep_domains_unfiltered - stage_dep_domains) or "{}",
|
||||
)
|
||||
|
||||
if timeout is None:
|
||||
|
||||
@@ -33,7 +33,7 @@ from .const import (
|
||||
)
|
||||
from .entity import AITaskEntity
|
||||
from .http import async_setup as async_setup_http
|
||||
from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data
|
||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
@@ -41,7 +41,6 @@ __all__ = [
|
||||
"AITaskEntityFeature",
|
||||
"GenDataTask",
|
||||
"GenDataTaskResult",
|
||||
"PlayMediaWithId",
|
||||
"async_generate_data",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.conversation import (
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.helpers.chat_session import ChatSession
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity):
|
||||
@contextlib.asynccontextmanager
|
||||
async def _async_get_ai_task_chat_log(
|
||||
self,
|
||||
session: ChatSession,
|
||||
task: GenDataTask,
|
||||
) -> AsyncGenerator[ChatLog]:
|
||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||
with (
|
||||
async_get_chat_session(self.hass) as session,
|
||||
async_get_chat_log(
|
||||
self.hass,
|
||||
session,
|
||||
@@ -79,19 +79,22 @@ class AITaskEntity(RestoreEntity):
|
||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
chat_log.async_add_user_content(UserContent(task.instructions))
|
||||
chat_log.async_add_user_content(
|
||||
UserContent(task.instructions, attachments=task.attachments)
|
||||
)
|
||||
|
||||
yield chat_log
|
||||
|
||||
@final
|
||||
async def internal_async_generate_data(
|
||||
self,
|
||||
session: ChatSession,
|
||||
task: GenDataTask,
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a gen data task."""
|
||||
self.__last_activity = dt_util.utcnow().isoformat()
|
||||
self.async_write_ha_state()
|
||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
||||
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
|
||||
return await self._async_generate_data(task, chat_log)
|
||||
|
||||
async def _async_generate_data(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "ai_task",
|
||||
"name": "AI Task",
|
||||
"after_dependencies": ["camera"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["conversation", "media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||
|
||||
@@ -10,13 +10,15 @@ generate_data:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
entity_id:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
filter:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
structure:
|
||||
advanced: true
|
||||
required: false
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
from dataclasses import dataclass
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components import camera, conversation, media_source
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PlayMediaWithId(media_source.PlayMedia):
|
||||
"""Play media with a media content ID."""
|
||||
|
||||
media_content_id: str
|
||||
"""Media source ID to play."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return media source ID as a string."""
|
||||
return f"<PlayMediaWithId {self.media_content_id}>"
|
||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
"""Save camera snapshot to temp file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(image.content)
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
async def async_generate_data(
|
||||
@@ -52,38 +55,79 @@ async def async_generate_data(
|
||||
)
|
||||
|
||||
# Resolve attachments
|
||||
resolved_attachments: list[PlayMediaWithId] | None = None
|
||||
resolved_attachments: list[conversation.Attachment] = []
|
||||
created_files: list[Path] = []
|
||||
|
||||
if attachments:
|
||||
if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features:
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support attachments"
|
||||
if (
|
||||
attachments
|
||||
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support attachments"
|
||||
)
|
||||
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
|
||||
# Special case for camera media sources
|
||||
if media_content_id.startswith("media-source://camera/"):
|
||||
# Extract entity_id from the media content ID
|
||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||
|
||||
# Get snapshot from camera
|
||||
image = await camera.async_get_image(hass, entity_id)
|
||||
|
||||
temp_filename = await hass.async_add_executor_job(
|
||||
_save_camera_snapshot, image
|
||||
)
|
||||
created_files.append(temp_filename)
|
||||
|
||||
resolved_attachments = []
|
||||
|
||||
for attachment in attachments:
|
||||
media = await media_source.async_resolve_media(
|
||||
hass, attachment["media_content_id"], None
|
||||
)
|
||||
resolved_attachments.append(
|
||||
PlayMediaWithId(
|
||||
**{
|
||||
field.name: getattr(media, field.name)
|
||||
for field in fields(media)
|
||||
},
|
||||
media_content_id=attachment["media_content_id"],
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Handle regular media sources
|
||||
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
||||
if media.path is None:
|
||||
raise HomeAssistantError(
|
||||
"Only local attachments are currently supported"
|
||||
)
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=media.mime_type,
|
||||
path=media.path,
|
||||
)
|
||||
)
|
||||
|
||||
return await entity.internal_async_generate_data(
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
structure=structure,
|
||||
attachments=resolved_attachments,
|
||||
with async_get_chat_session(hass) as session:
|
||||
if created_files:
|
||||
|
||||
def cleanup_files() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
for file in created_files:
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
@callback
|
||||
def cleanup_files_callback() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
hass.async_add_executor_job(cleanup_files)
|
||||
|
||||
session.async_on_cleanup(cleanup_files_callback)
|
||||
|
||||
return await entity.internal_async_generate_data(
|
||||
session,
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
structure=structure,
|
||||
attachments=resolved_attachments or None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -99,7 +143,7 @@ class GenDataTask:
|
||||
structure: vol.Schema | None = None
|
||||
"""Optional structure for the data to be generated."""
|
||||
|
||||
attachments: list[PlayMediaWithId] | None = None
|
||||
attachments: list[conversation.Attachment] | None = None
|
||||
"""List of attachments to go along the instructions."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average"
|
||||
CONF_CLIP_NEGATIVE: Final = "clip_negatives"
|
||||
DOMAIN: Final = "airq"
|
||||
MANUFACTURER: Final = "CorantGmbH"
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
||||
UPDATE_INTERVAL: float = 10.0
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
"health_index": {
|
||||
"default": "mdi:heart-pulse"
|
||||
},
|
||||
"absolute_humidity": {
|
||||
"default": "mdi:water"
|
||||
},
|
||||
"oxygen": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
@@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import AirQConfigEntry, AirQCoordinator
|
||||
from .const import (
|
||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
)
|
||||
from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="humidity_abs",
|
||||
translation_key="absolute_humidity",
|
||||
device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
|
||||
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("humidity_abs"),
|
||||
|
||||
@@ -93,9 +93,6 @@
|
||||
"health_index": {
|
||||
"name": "Health index"
|
||||
},
|
||||
"absolute_humidity": {
|
||||
"name": "Absolute humidity"
|
||||
},
|
||||
"hydrogen": {
|
||||
"name": "Hydrogen"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.12"]
|
||||
"requirements": ["aioairzone-cloud==0.6.14"]
|
||||
}
|
||||
|
||||
@@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity):
|
||||
):
|
||||
yield AlexaThermostatController(self.hass, self.entity)
|
||||
yield AlexaTemperatureSensor(self.hass, self.entity)
|
||||
if self.entity.domain == water_heater.DOMAIN and (
|
||||
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||
if (
|
||||
self.entity.domain == water_heater.DOMAIN
|
||||
and (
|
||||
supported_features
|
||||
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||
)
|
||||
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
|
||||
):
|
||||
yield AlexaModeController(
|
||||
self.entity,
|
||||
@@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity):
|
||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
||||
)
|
||||
force_range_controller = False
|
||||
if supported & fan.FanEntityFeature.PRESET_MODE:
|
||||
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
|
||||
fan.ATTR_PRESET_MODES
|
||||
):
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
||||
)
|
||||
@@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity):
|
||||
yield AlexaPowerController(self.entity)
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
||||
if activities and supported & remote.RemoteEntityFeature.ACTIVITY:
|
||||
if (
|
||||
activities
|
||||
and (supported & remote.RemoteEntityFeature.ACTIVITY)
|
||||
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
|
||||
):
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
||||
)
|
||||
@@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity):
|
||||
"""Yield the supported interfaces."""
|
||||
yield AlexaPowerController(self.entity)
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported & humidifier.HumidifierEntityFeature.MODES:
|
||||
if (
|
||||
supported & humidifier.HumidifierEntityFeature.MODES
|
||||
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==3.2.8"]
|
||||
"requirements": ["aioamazondevices==3.2.10"]
|
||||
}
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
|
||||
import amberelectric
|
||||
|
||||
from homeassistant.components.sensor import ConfigType
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_SITE_ID, PLATFORMS
|
||||
from .const import CONF_SITE_ID, DOMAIN, PLATFORMS
|
||||
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
|
||||
from .services import setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Amber component."""
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
"""Amber Electric Constants."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "amberelectric"
|
||||
DOMAIN: Final = "amberelectric"
|
||||
CONF_SITE_NAME = "site_name"
|
||||
CONF_SITE_ID = "site_id"
|
||||
|
||||
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
||||
ATTR_CHANNEL_TYPE = "channel_type"
|
||||
|
||||
ATTRIBUTION = "Data provided by Amber Electric"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
SERVICE_GET_FORECASTS = "get_forecasts"
|
||||
|
||||
GENERAL_CHANNEL = "general"
|
||||
CONTROLLED_LOAD_CHANNEL = "controlled_load"
|
||||
FEED_IN_CHANNEL = "feed_in"
|
||||
|
||||
@@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval
|
||||
from amberelectric.models.channel import ChannelType
|
||||
from amberelectric.models.current_interval import CurrentInterval
|
||||
from amberelectric.models.forecast_interval import ForecastInterval
|
||||
from amberelectric.models.price_descriptor import PriceDescriptor
|
||||
from amberelectric.rest import ApiException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER
|
||||
from .helpers import normalize_descriptor
|
||||
|
||||
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
|
||||
|
||||
@@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
|
||||
return interval.channel_type == ChannelType.FEEDIN
|
||||
|
||||
|
||||
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
||||
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
||||
if descriptor is None:
|
||||
return None
|
||||
if descriptor.value == "spike":
|
||||
return "spike"
|
||||
if descriptor.value == "high":
|
||||
return "high"
|
||||
if descriptor.value == "neutral":
|
||||
return "neutral"
|
||||
if descriptor.value == "low":
|
||||
return "low"
|
||||
if descriptor.value == "veryLow":
|
||||
return "very_low"
|
||||
if descriptor.value == "extremelyLow":
|
||||
return "extremely_low"
|
||||
if descriptor.value == "negative":
|
||||
return "negative"
|
||||
return None
|
||||
|
||||
|
||||
class AmberUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
|
||||
|
||||
@@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
|
||||
"grid": {},
|
||||
}
|
||||
try:
|
||||
data = self._api.get_current_prices(self.site_id, next=48)
|
||||
data = self._api.get_current_prices(self.site_id, next=288)
|
||||
intervals = [interval.actual_instance for interval in data]
|
||||
except ApiException as api_exception:
|
||||
raise UpdateFailed("Missing price data, skipping update") from api_exception
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Formatting helpers used to convert things."""
|
||||
|
||||
from amberelectric.models.price_descriptor import PriceDescriptor
|
||||
|
||||
DESCRIPTOR_MAP: dict[str, str] = {
|
||||
PriceDescriptor.SPIKE: "spike",
|
||||
PriceDescriptor.HIGH: "high",
|
||||
PriceDescriptor.NEUTRAL: "neutral",
|
||||
PriceDescriptor.LOW: "low",
|
||||
PriceDescriptor.VERYLOW: "very_low",
|
||||
PriceDescriptor.EXTREMELYLOW: "extremely_low",
|
||||
PriceDescriptor.NEGATIVE: "negative",
|
||||
}
|
||||
|
||||
|
||||
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
||||
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
||||
if descriptor in DESCRIPTOR_MAP:
|
||||
return DESCRIPTOR_MAP[descriptor]
|
||||
return None
|
||||
|
||||
|
||||
def format_cents_to_dollars(cents: float) -> float:
|
||||
"""Return a formatted conversion from cents to dollars."""
|
||||
return round(cents / 100, 2)
|
||||
@@ -22,5 +22,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_forecasts": {
|
||||
"service": "mdi:transmission-tower"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTRIBUTION
|
||||
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor
|
||||
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
|
||||
from .helpers import format_cents_to_dollars, normalize_descriptor
|
||||
|
||||
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
|
||||
|
||||
|
||||
def format_cents_to_dollars(cents: float) -> float:
|
||||
"""Return a formatted conversion from cents to dollars."""
|
||||
return round(cents / 100, 2)
|
||||
|
||||
|
||||
def friendly_channel_type(channel_type: str) -> str:
|
||||
"""Return a human readable version of the channel type."""
|
||||
if channel_type == "controlled_load":
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Amber Electric Service class."""
|
||||
|
||||
from amberelectric.models.channel import ChannelType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import (
|
||||
ATTR_CHANNEL_TYPE,
|
||||
ATTR_CONFIG_ENTRY_ID,
|
||||
CONTROLLED_LOAD_CHANNEL,
|
||||
DOMAIN,
|
||||
FEED_IN_CHANNEL,
|
||||
GENERAL_CHANNEL,
|
||||
SERVICE_GET_FORECASTS,
|
||||
)
|
||||
from .coordinator import AmberConfigEntry
|
||||
from .helpers import format_cents_to_dollars, normalize_descriptor
|
||||
|
||||
GET_FORECASTS_SCHEMA = vol.Schema(
|
||||
{
|
||||
ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}),
|
||||
ATTR_CHANNEL_TYPE: vol.In(
|
||||
[GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
|
||||
"""Get the Amber config entry."""
|
||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": config_entry_id},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
|
||||
"""Return an array of forecasts."""
|
||||
results: list[JsonValueType] = []
|
||||
|
||||
if channel_type not in data["forecasts"]:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="channel_not_found",
|
||||
translation_placeholders={"channel_type": channel_type},
|
||||
)
|
||||
|
||||
intervals = data["forecasts"][channel_type]
|
||||
|
||||
for interval in intervals:
|
||||
datum = {}
|
||||
datum["duration"] = interval.duration
|
||||
datum["date"] = interval.var_date.isoformat()
|
||||
datum["nem_date"] = interval.nem_time.isoformat()
|
||||
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
|
||||
if interval.channel_type == ChannelType.FEEDIN:
|
||||
datum["per_kwh"] = datum["per_kwh"] * -1
|
||||
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
|
||||
datum["start_time"] = interval.start_time.isoformat()
|
||||
datum["end_time"] = interval.end_time.isoformat()
|
||||
datum["renewables"] = round(interval.renewables)
|
||||
datum["spike_status"] = interval.spike_status.value
|
||||
datum["descriptor"] = normalize_descriptor(interval.descriptor)
|
||||
|
||||
if interval.range is not None:
|
||||
datum["range_min"] = format_cents_to_dollars(interval.range.min)
|
||||
datum["range_max"] = format_cents_to_dollars(interval.range.max)
|
||||
|
||||
if interval.advanced_price is not None:
|
||||
multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1
|
||||
datum["advanced_price_low"] = multiplier * format_cents_to_dollars(
|
||||
interval.advanced_price.low
|
||||
)
|
||||
datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars(
|
||||
interval.advanced_price.predicted
|
||||
)
|
||||
datum["advanced_price_high"] = multiplier * format_cents_to_dollars(
|
||||
interval.advanced_price.high
|
||||
)
|
||||
|
||||
results.append(datum)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Amber integration."""
|
||||
|
||||
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
|
||||
channel_type = call.data[ATTR_CHANNEL_TYPE]
|
||||
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
|
||||
coordinator = entry.runtime_data
|
||||
forecasts = get_forecasts(channel_type, coordinator.data)
|
||||
return {"forecasts": forecasts}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_FORECASTS,
|
||||
handle_get_forecasts,
|
||||
GET_FORECASTS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
get_forecasts:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: amberelectric
|
||||
channel_type:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- general
|
||||
- controlled_load
|
||||
- feed_in
|
||||
translation_key: channel_type
|
||||
@@ -1,25 +1,61 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"no_site": "No site provided",
|
||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"site": {
|
||||
"data": {
|
||||
"site_id": "Site NMI",
|
||||
"site_name": "Site name"
|
||||
},
|
||||
"description": "Select the NMI of the site you would like to add"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_token": "[%key:common::config_flow::data::api_token%]",
|
||||
"site_id": "Site ID"
|
||||
},
|
||||
"description": "Go to {api_url} to generate an API key"
|
||||
},
|
||||
"site": {
|
||||
"data": {
|
||||
"site_id": "Site NMI",
|
||||
"site_name": "Site Name"
|
||||
},
|
||||
"description": "Select the NMI of the site you would like to add"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_forecasts": {
|
||||
"name": "Get price forecasts",
|
||||
"description": "Retrieves price forecasts from Amber Electric for a site.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "The config entry of the site to get forecasts for.",
|
||||
"name": "Config entry"
|
||||
},
|
||||
"channel_type": {
|
||||
"name": "Channel type",
|
||||
"description": "The channel to get forecasts for."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"integration_not_found": {
|
||||
"message": "Config entry \"{target}\" not found in registry."
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"no_site": "No site provided",
|
||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"channel_not_found": {
|
||||
"message": "There is no {channel_type} channel at this site."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"channel_type": {
|
||||
"options": {
|
||||
"general": "General",
|
||||
"controlled_load": "Controlled load",
|
||||
"feed_in": "Feed-in"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["amcrest"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["amcrest==1.9.8"]
|
||||
"requirements": ["amcrest==1.9.9"]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307"
|
||||
RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
RECOMMENDED_MAX_TOKENS = 1024
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.0"],
|
||||
"requirements": ["pyatv==0.16.1"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
||||
@@ -38,8 +38,6 @@ from .pipeline import (
|
||||
async_create_default_pipeline,
|
||||
async_get_pipeline,
|
||||
async_get_pipelines,
|
||||
async_migrate_engine,
|
||||
async_run_migrations,
|
||||
async_setup_pipeline_store,
|
||||
async_update_pipeline,
|
||||
)
|
||||
@@ -61,7 +59,6 @@ __all__ = (
|
||||
"WakeWordSettings",
|
||||
"async_create_default_pipeline",
|
||||
"async_get_pipelines",
|
||||
"async_migrate_engine",
|
||||
"async_pipeline_from_audio_stream",
|
||||
"async_setup",
|
||||
"async_update_pipeline",
|
||||
@@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.data[DATA_LAST_WAKE_UP] = {}
|
||||
|
||||
await async_setup_pipeline_store(hass)
|
||||
await async_run_migrations(hass)
|
||||
async_register_websocket_api(hass)
|
||||
|
||||
return True
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
DOMAIN = "assist_pipeline"
|
||||
|
||||
DATA_CONFIG = f"{DOMAIN}.config"
|
||||
DATA_MIGRATIONS = f"{DOMAIN}_migrations"
|
||||
|
||||
DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
import wave
|
||||
|
||||
import hass_nabucasa
|
||||
@@ -49,7 +49,6 @@ from .const import (
|
||||
CONF_DEBUG_RECORDING_DIR,
|
||||
DATA_CONFIG,
|
||||
DATA_LAST_WAKE_UP,
|
||||
DATA_MIGRATIONS,
|
||||
DOMAIN,
|
||||
MS_PER_CHUNK,
|
||||
SAMPLE_CHANNELS,
|
||||
@@ -2059,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
|
||||
return PipelineData(pipeline_store)
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_engine(
|
||||
hass: HomeAssistant,
|
||||
engine_type: Literal["conversation", "stt", "tts", "wake_word"],
|
||||
old_value: str,
|
||||
new_value: str,
|
||||
) -> None:
|
||||
"""Register a migration of an engine used in pipelines."""
|
||||
hass.data.setdefault(DATA_MIGRATIONS, {})[engine_type] = (old_value, new_value)
|
||||
|
||||
# Run migrations when config is already loaded
|
||||
if DATA_CONFIG in hass.data:
|
||||
hass.async_create_background_task(
|
||||
async_run_migrations(hass), "assist_pipeline_migration", eager_start=True
|
||||
)
|
||||
|
||||
|
||||
async def async_run_migrations(hass: HomeAssistant) -> None:
|
||||
"""Run pipeline migrations."""
|
||||
if not (migrations := hass.data.get(DATA_MIGRATIONS)):
|
||||
return
|
||||
|
||||
engine_attr = {
|
||||
"conversation": "conversation_engine",
|
||||
"stt": "stt_engine",
|
||||
"tts": "tts_engine",
|
||||
"wake_word": "wake_word_entity",
|
||||
}
|
||||
|
||||
updates = []
|
||||
|
||||
for pipeline in async_get_pipelines(hass):
|
||||
attr_updates = {}
|
||||
for engine_type, (old_value, new_value) in migrations.items():
|
||||
if getattr(pipeline, engine_attr[engine_type]) == old_value:
|
||||
attr_updates[engine_attr[engine_type]] = new_value
|
||||
|
||||
if attr_updates:
|
||||
updates.append((pipeline, attr_updates))
|
||||
|
||||
for pipeline, attr_updates in updates:
|
||||
await async_update_pipeline(hass, pipeline, **attr_updates)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConversationData:
|
||||
"""Hold data for the duration of a conversation."""
|
||||
|
||||
@@ -68,9 +68,10 @@ ask_question:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
filter:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
question:
|
||||
required: false
|
||||
example: "What kind of music would you like to play?"
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
API_CO2 = "carbon_dioxide"
|
||||
API_DEW_POINT = "dew_point"
|
||||
API_DUST = "dust"
|
||||
API_HUMID = "humidity"
|
||||
API_LUX = "illuminance"
|
||||
|
||||
@@ -34,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
API_CO2,
|
||||
API_DEW_POINT,
|
||||
API_DUST,
|
||||
API_HUMID,
|
||||
API_LUX,
|
||||
@@ -110,6 +111,15 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = (
|
||||
unique_id_tag="CO2", # matches legacy format
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AwairSensorEntityDescription(
|
||||
key=API_DEW_POINT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
translation_key="dew_point",
|
||||
unique_id_tag="dew_point",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
},
|
||||
"sound_level": {
|
||||
"name": "Sound level"
|
||||
},
|
||||
"dew_point": {
|
||||
"name": "Dew point"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
hub.setup()
|
||||
|
||||
config_entry.add_update_listener(hub.async_new_address_callback)
|
||||
config_entry.async_on_unload(
|
||||
config_entry.add_update_listener(hub.async_new_address_callback)
|
||||
)
|
||||
config_entry.async_on_unload(hub.teardown)
|
||||
config_entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown)
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.22.3",
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bleak==1.0.1",
|
||||
"bleak-retry-connector==4.0.0",
|
||||
"bluetooth-adapters==2.0.0",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.49.0"
|
||||
"habluetooth==4.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,20 +8,33 @@ from bring_api import Bring
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
BringActivityCoordinator,
|
||||
BringConfigEntry,
|
||||
BringCoordinators,
|
||||
BringDataUpdateCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bring! services."""
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
|
||||
"""Set up Bring! from a config entry."""
|
||||
|
||||
|
||||
@@ -7,5 +7,8 @@ DOMAIN = "bring"
|
||||
ATTR_SENDER: Final = "sender"
|
||||
ATTR_ITEM_NAME: Final = "item"
|
||||
ATTR_NOTIFICATION_TYPE: Final = "message"
|
||||
|
||||
ATTR_REACTION: Final = "reaction"
|
||||
ATTR_ACTIVITY: Final = "uuid"
|
||||
ATTR_RECEIVER: Final = "publicUserUuid"
|
||||
SERVICE_PUSH_NOTIFICATION = "send_message"
|
||||
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"services": {
|
||||
"send_message": {
|
||||
"service": "mdi:cellphone-message"
|
||||
},
|
||||
"send_reaction": {
|
||||
"service": "mdi:thumb-up"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Actions for Bring! integration."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bring_api import (
|
||||
ActivityType,
|
||||
BringAuthException,
|
||||
BringNotificationType,
|
||||
BringRequestException,
|
||||
ReactionType,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTIVITY,
|
||||
ATTR_REACTION,
|
||||
ATTR_RECEIVER,
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
)
|
||||
from .coordinator import BringConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_REACTION): vol.All(
|
||||
vol.Upper,
|
||||
vol.Coerce(ReactionType),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if TYPE_CHECKING:
|
||||
assert entry
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Bring! integration."""
|
||||
|
||||
async def async_send_activity_stream_reaction(call: ServiceCall) -> None:
|
||||
"""Send a reaction in response to recent activity of a list member."""
|
||||
|
||||
if (
|
||||
not (state := hass.states.get(call.data[ATTR_ENTITY_ID]))
|
||||
or not (entity := er.async_get(hass).async_get(call.data[ATTR_ENTITY_ID]))
|
||||
or not entity.config_entry_id
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entity_not_found",
|
||||
translation_placeholders={
|
||||
ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID],
|
||||
},
|
||||
)
|
||||
config_entry = get_config_entry(hass, entity.config_entry_id)
|
||||
|
||||
coordinator = config_entry.runtime_data.data
|
||||
|
||||
list_uuid = entity.unique_id.split("_")[1]
|
||||
|
||||
activity = state.attributes[ATTR_EVENT_TYPE]
|
||||
|
||||
reaction: ReactionType = call.data[ATTR_REACTION]
|
||||
|
||||
if not activity:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="activity_not_found",
|
||||
)
|
||||
try:
|
||||
await coordinator.bring.notify(
|
||||
list_uuid,
|
||||
BringNotificationType.LIST_ACTIVITY_STREAM_REACTION,
|
||||
receiver=state.attributes[ATTR_RECEIVER],
|
||||
activity=state.attributes[ATTR_ACTIVITY],
|
||||
activity_type=ActivityType(activity.upper()),
|
||||
reaction=reaction,
|
||||
)
|
||||
except (BringRequestException, BringAuthException) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reaction_request_failed",
|
||||
) from e
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
async_send_activity_stream_reaction,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
|
||||
)
|
||||
@@ -21,3 +21,28 @@ send_message:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
send_reaction:
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- integration: bring
|
||||
domain: event
|
||||
example: event.shopping_list
|
||||
reaction:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: 👍🏼
|
||||
value: thumbs_up
|
||||
- label: 🧐
|
||||
value: monocle
|
||||
- label: 🤤
|
||||
value: drooling
|
||||
- label: ❤️
|
||||
value: heart
|
||||
mode: dropdown
|
||||
example: thumbs_up
|
||||
|
||||
@@ -144,6 +144,19 @@
|
||||
},
|
||||
"notify_request_failed": {
|
||||
"message": "Failed to send push notification for Bring! due to a connection error, try again later"
|
||||
},
|
||||
"reaction_request_failed": {
|
||||
"message": "Failed to send reaction for Bring! due to a connection error, try again later"
|
||||
},
|
||||
"activity_not_found": {
|
||||
"message": "Failed to send reaction for Bring! — No recent activity found"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Failed to send reaction for Bring! — Unknown entity {entity_id}"
|
||||
},
|
||||
|
||||
"entry_not_loaded": {
|
||||
"message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -164,6 +177,20 @@
|
||||
"description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'"
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_reaction": {
|
||||
"name": "Send reaction",
|
||||
"description": "Sends a reaction to a recent activity on a Bring! list by a member of the shared list.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Activities",
|
||||
"description": "Select the Bring! activities event entity for reacting to its most recent event"
|
||||
},
|
||||
"reaction": {
|
||||
"name": "Reaction",
|
||||
"description": "Type of reaction to send in response."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = {
|
||||
Platform.SELECT: {"HYS"},
|
||||
Platform.SENSOR: {
|
||||
"A1",
|
||||
"A2",
|
||||
"MP1S",
|
||||
"RM4MINI",
|
||||
"RM4PRO",
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
@@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
key="air_quality",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="pm10",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="pm2_5",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="pm1",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
|
||||
@@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager
|
||||
"""Return an update manager for a given Broadlink device."""
|
||||
update_managers: dict[str, type[BroadlinkUpdateManager]] = {
|
||||
"A1": BroadlinkA1UpdateManager,
|
||||
"A2": BroadlinkA2UpdateManager,
|
||||
"BG1": BroadlinkBG1UpdateManager,
|
||||
"HYS": BroadlinkThermostatUpdateManager,
|
||||
"LB1": BroadlinkLB1UpdateManager,
|
||||
@@ -118,6 +119,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]):
|
||||
return await self.device.async_request(self.device.api.check_sensors_raw)
|
||||
|
||||
|
||||
class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]):
|
||||
"""Manages updates for Broadlink A2 devices."""
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
async def async_fetch_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.check_sensors_raw)
|
||||
|
||||
|
||||
class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]):
|
||||
"""Manages updates for Broadlink MP1 devices."""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||
"requirements": ["brother==4.3.1"],
|
||||
"requirements": ["brother==5.0.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_printer._tcp.local.",
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
|
||||
|
||||
@@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
host: str
|
||||
port: int
|
||||
mac: str
|
||||
passkey: str | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
def __init__(self) -> None:
|
||||
"""Initialize BSBLan flow."""
|
||||
self.host: str | None = None
|
||||
self.port: int = DEFAULT_PORT
|
||||
self.mac: str | None = None
|
||||
self.passkey: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
self._auth_required = True
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -41,9 +45,111 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.username = user_input.get(CONF_USERNAME)
|
||||
self.password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
return await self._validate_and_create()
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle Zeroconf discovery."""
|
||||
|
||||
self.host = str(discovery_info.ip_address)
|
||||
self.port = discovery_info.port or DEFAULT_PORT
|
||||
|
||||
# Get MAC from properties
|
||||
self.mac = discovery_info.properties.get("mac")
|
||||
|
||||
# If MAC was found in zeroconf, use it immediately
|
||||
if self.mac:
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# MAC not available from zeroconf - check for existing host/port first
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: self.host, CONF_PORT: self.port}
|
||||
)
|
||||
|
||||
# Try to get device info without authentication to minimize discovery popup
|
||||
config = BSBLANConfig(host=self.host, port=self.port)
|
||||
session = async_get_clientsession(self.hass)
|
||||
bsblan = BSBLAN(config, session)
|
||||
try:
|
||||
device = await bsblan.device()
|
||||
except BSBLANError:
|
||||
# Device requires authentication - proceed to discovery confirm
|
||||
self.mac = None
|
||||
else:
|
||||
self.mac = device.MAC
|
||||
|
||||
# Got MAC without auth - set unique ID and check for existing device
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
)
|
||||
# No auth needed, so we can proceed to a confirmation step without fields
|
||||
self._auth_required = False
|
||||
|
||||
# Proceed to get credentials
|
||||
self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"}
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle getting credentials for discovered device."""
|
||||
if user_input is None:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PASSKEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
if not self._auth_required:
|
||||
data_schema = vol.Schema({})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=data_schema,
|
||||
description_placeholders={"host": str(self.host)},
|
||||
)
|
||||
|
||||
if not self._auth_required:
|
||||
return self._async_create_entry()
|
||||
|
||||
self.passkey = user_input.get(CONF_PASSKEY)
|
||||
self.username = user_input.get(CONF_USERNAME)
|
||||
self.password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
return await self._validate_and_create(is_discovery=True)
|
||||
|
||||
async def _validate_and_create(
|
||||
self, is_discovery: bool = False
|
||||
) -> ConfigFlowResult:
|
||||
"""Validate device connection and create entry."""
|
||||
try:
|
||||
await self._get_bsblan_info()
|
||||
await self._get_bsblan_info(is_discovery=is_discovery)
|
||||
except BSBLANError:
|
||||
if is_discovery:
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PASSKEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
description_placeholders={"host": str(self.host)},
|
||||
)
|
||||
return self._show_setup_form({"base": "cannot_connect"})
|
||||
|
||||
return self._async_create_entry()
|
||||
@@ -67,6 +173,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@callback
|
||||
def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
return self.async_create_entry(
|
||||
title=format_mac(self.mac),
|
||||
data={
|
||||
@@ -78,8 +185,10 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None:
|
||||
"""Get device information from an BSBLAN device."""
|
||||
async def _get_bsblan_info(
|
||||
self, raise_on_progress: bool = True, is_discovery: bool = False
|
||||
) -> None:
|
||||
"""Get device information from a BSBLAN device."""
|
||||
config = BSBLANConfig(
|
||||
host=self.host,
|
||||
passkey=self.passkey,
|
||||
@@ -90,11 +199,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
session = async_get_clientsession(self.hass)
|
||||
bsblan = BSBLAN(config, session)
|
||||
device = await bsblan.device()
|
||||
self.mac = device.MAC
|
||||
retrieved_mac = device.MAC
|
||||
|
||||
await self.async_set_unique_id(
|
||||
format_mac(self.mac), raise_on_progress=raise_on_progress
|
||||
)
|
||||
# Handle unique ID assignment based on whether MAC was available from zeroconf
|
||||
if not self.mac:
|
||||
# MAC wasn't available from zeroconf, now we have it from API
|
||||
self.mac = retrieved_mac
|
||||
await self.async_set_unique_id(
|
||||
format_mac(self.mac), raise_on_progress=raise_on_progress
|
||||
)
|
||||
|
||||
# Always allow updating host/port for both user and discovery flows
|
||||
# This ensures connectivity is maintained when devices change IP addresses
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
|
||||
@@ -7,5 +7,11 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==2.1.0"]
|
||||
"requirements": ["python-bsblan==2.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
"name": "bsb-lan*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData
|
||||
from .coordinator import BSBLanCoordinatorData
|
||||
from .entity import BSBLanEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BSBLanSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -13,7 +13,25 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your BSB-Lan device."
|
||||
"host": "The hostname or IP address of your BSB-Lan device.",
|
||||
"port": "The port number of your BSB-Lan device.",
|
||||
"passkey": "The passkey for your BSB-Lan device.",
|
||||
"username": "The username for your BSB-Lan device.",
|
||||
"password": "The password for your BSB-Lan device."
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"title": "BSB-Lan device discovered",
|
||||
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
|
||||
"data": {
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.105.0"],
|
||||
"requirements": ["hass-nabucasa==0.107.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ from .agent_manager import (
|
||||
from .chat_log import (
|
||||
AssistantContent,
|
||||
AssistantContentDeltaDict,
|
||||
Attachment,
|
||||
ChatLog,
|
||||
Content,
|
||||
ConverseError,
|
||||
@@ -51,7 +52,6 @@ from .const import (
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
OLD_HOME_ASSISTANT_AGENT,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
@@ -65,9 +65,9 @@ from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"HOME_ASSISTANT_AGENT",
|
||||
"OLD_HOME_ASSISTANT_AGENT",
|
||||
"AssistantContent",
|
||||
"AssistantContentDeltaDict",
|
||||
"Attachment",
|
||||
"ChatLog",
|
||||
"Content",
|
||||
"ConversationEntity",
|
||||
@@ -270,15 +270,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass, entity_component, config.get(DOMAIN, {}).get("intents", {})
|
||||
)
|
||||
|
||||
# Temporary migration. We can remove this in 2024.10
|
||||
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
|
||||
async_migrate_engine,
|
||||
)
|
||||
|
||||
async_migrate_engine(
|
||||
hass, "conversation", OLD_HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT
|
||||
)
|
||||
|
||||
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
||||
"""Parse text into commands."""
|
||||
text = service.data[ATTR_TEXT]
|
||||
|
||||
@@ -12,12 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
from .const import (
|
||||
DATA_COMPONENT,
|
||||
DATA_DEFAULT_ENTITY,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
OLD_HOME_ASSISTANT_AGENT,
|
||||
)
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import (
|
||||
AbstractConversationAgent,
|
||||
@@ -54,7 +49,7 @@ def async_get_agent(
|
||||
hass: HomeAssistant, agent_id: str | None = None
|
||||
) -> AbstractConversationAgent | ConversationEntity | None:
|
||||
"""Get specified agent."""
|
||||
if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT):
|
||||
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
|
||||
return hass.data[DATA_DEFAULT_ENTITY]
|
||||
|
||||
if "." in agent_id:
|
||||
|
||||
@@ -8,6 +8,7 @@ from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -136,6 +137,21 @@ class UserContent:
|
||||
|
||||
role: Literal["user"] = field(init=False, default="user")
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attachment:
|
||||
"""Attachment for a chat message."""
|
||||
|
||||
media_content_id: str
|
||||
"""Media content ID of the attachment."""
|
||||
|
||||
mime_type: str
|
||||
"""MIME type of the attachment."""
|
||||
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -16,7 +16,6 @@ if TYPE_CHECKING:
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
|
||||
OLD_HOME_ASSISTANT_AGENT = "homeassistant"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
ATTR_LANGUAGE = "language"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_SOURCE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -9,12 +11,18 @@ from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Derivative from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass, entry.entry_id, entry.options[CONF_SOURCE]
|
||||
)
|
||||
@@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options={**entry.options, CONF_SOURCE: source_entity_id},
|
||||
)
|
||||
|
||||
async def source_entity_removed() -> None:
|
||||
# The source entity has been removed, we need to clean the device links.
|
||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
hass, entry.options[CONF_SOURCE]
|
||||
),
|
||||
source_entity_id_or_uuid=entry.options[CONF_SOURCE],
|
||||
source_entity_removed=source_entity_removed,
|
||||
)
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
|
||||
@@ -54,3 +58,51 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry)
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
if config_entry.minor_version < 2:
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
if new_options.get("unit_prefix") == "none":
|
||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||
del new_options["unit_prefix"]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=new_options, version=1, minor_version=2
|
||||
)
|
||||
|
||||
if config_entry.minor_version < 3:
|
||||
# Remove the derivative config entry from the source device
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, config_entry.options[CONF_SOURCE]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, version=1, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -141,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||
"""Return config entry title."""
|
||||
return cast(str, options[CONF_NAME])
|
||||
|
||||
@@ -34,8 +34,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -118,30 +117,21 @@ async def async_setup_entry(
|
||||
registry, config_entry.options[CONF_SOURCE]
|
||||
)
|
||||
|
||||
device_info = async_device_info_to_link_from_entity(
|
||||
hass,
|
||||
source_entity_id,
|
||||
)
|
||||
|
||||
if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
|
||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||
unit_prefix = None
|
||||
|
||||
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
||||
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
||||
else:
|
||||
max_sub_interval = None
|
||||
|
||||
derivative_sensor = DerivativeSensor(
|
||||
hass,
|
||||
name=config_entry.title,
|
||||
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
||||
source_entity=source_entity_id,
|
||||
time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]),
|
||||
unique_id=config_entry.entry_id,
|
||||
unit_of_measurement=None,
|
||||
unit_prefix=unit_prefix,
|
||||
unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX),
|
||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||
device_info=device_info,
|
||||
max_sub_interval=max_sub_interval,
|
||||
)
|
||||
|
||||
@@ -156,6 +146,7 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the derivative sensor."""
|
||||
derivative = DerivativeSensor(
|
||||
hass,
|
||||
name=config.get(CONF_NAME),
|
||||
round_digits=config[CONF_ROUND_DIGITS],
|
||||
source_entity=config[CONF_SOURCE],
|
||||
@@ -178,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
name: str | None,
|
||||
round_digits: int,
|
||||
@@ -188,11 +180,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
unit_time: UnitOfTime,
|
||||
max_sub_interval: timedelta | None,
|
||||
unique_id: str | None,
|
||||
device_info: DeviceInfo | None = None,
|
||||
) -> None:
|
||||
"""Initialize the derivative sensor."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = device_info
|
||||
self.device_entry = async_entity_id_to_device(
|
||||
hass,
|
||||
source_entity,
|
||||
)
|
||||
self._sensor_source_id = source_entity
|
||||
self._round_digits = round_digits
|
||||
self._attr_native_value = round(Decimal(0), round_digits)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
integration_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.json import (
|
||||
@@ -187,6 +188,7 @@ def async_format_manifest(manifest: Manifest) -> Manifest:
|
||||
async def _async_get_json_file_response(
|
||||
hass: HomeAssistant,
|
||||
data: Mapping[str, Any],
|
||||
data_issues: list[dict[str, Any]] | None,
|
||||
filename: str,
|
||||
domain: str,
|
||||
d_id: str,
|
||||
@@ -213,6 +215,8 @@ async def _async_get_json_file_response(
|
||||
"setup_times": async_get_domain_setup_times(hass, domain),
|
||||
"data": data,
|
||||
}
|
||||
if data_issues is not None:
|
||||
payload["issues"] = data_issues
|
||||
try:
|
||||
json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder)
|
||||
except TypeError:
|
||||
@@ -275,6 +279,14 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
|
||||
|
||||
filename = f"{config_entry.domain}-{config_entry.entry_id}"
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
issues = issue_registry.issues
|
||||
data_issues = [
|
||||
issue_reg.to_json()
|
||||
for issue_id, issue_reg in issues.items()
|
||||
if issue_id[0] == config_entry.domain
|
||||
]
|
||||
|
||||
if not device_diagnostics:
|
||||
# Config entry diagnostics
|
||||
if info.config_entry_diagnostics is None:
|
||||
@@ -282,7 +294,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
|
||||
data = await info.config_entry_diagnostics(hass, config_entry)
|
||||
filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}"
|
||||
return await _async_get_json_file_response(
|
||||
hass, data, filename, config_entry.domain, d_id
|
||||
hass, data, data_issues, filename, config_entry.domain, d_id
|
||||
)
|
||||
|
||||
# Device diagnostics
|
||||
@@ -300,5 +312,5 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
|
||||
|
||||
data = await info.device_diagnostics(hass, config_entry, device)
|
||||
return await _async_get_json_file_response(
|
||||
hass, data, filename, config_entry.domain, d_id, sub_id
|
||||
hass, data, data_issues, filename, config_entry.domain, d_id, sub_id
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import create_async_httpx_client
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
@@ -30,10 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -
|
||||
# if no exception is raised everything is fine to go
|
||||
meters = await client.meters()
|
||||
except discovergyError.InvalidLogin as err:
|
||||
raise ConfigEntryAuthFailed("Invalid email or password") from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise ConfigEntryNotReady(
|
||||
"Unexpected error while while getting meters"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_meters_setup",
|
||||
) from err
|
||||
|
||||
# Init coordinators for meters
|
||||
|
||||
@@ -14,6 +14,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]]
|
||||
@@ -51,7 +53,12 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]):
|
||||
)
|
||||
except InvalidLogin as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Auth expired while fetching last reading"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (HTTPError, DiscovergyClientError) as err:
|
||||
raise UpdateFailed(f"Error while fetching last reading: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reading_update_failed",
|
||||
translation_placeholders={"meter_id": self.meter.meter_id},
|
||||
) from err
|
||||
|
||||
@@ -72,12 +72,16 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional icons.
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
No configuration besides credentials.
|
||||
New credentials will create a new config entry.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed. Please check your inexogy email and password."
|
||||
},
|
||||
"cannot_connect_meters_setup": {
|
||||
"message": "Failed to connect and retrieve meters from inexogy during setup. Please ensure the service is reachable and try again."
|
||||
},
|
||||
"reading_update_failed": {
|
||||
"message": "Error fetching the latest reading for meter {meter_id} from inexogy. The service might be temporarily unavailable or there's a connection issue. Check logs for more details."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"api_endpoint_reachable": "inexogy API endpoint reachable"
|
||||
|
||||
@@ -106,6 +106,7 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
EcoWittSensorTypes.CO2_PPM: SensorEntityDescription(
|
||||
key="CO2_PPM",
|
||||
@@ -191,12 +192,14 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription(
|
||||
key="SPEED_MPH",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription(
|
||||
key="PRESSURE_HPA",
|
||||
|
||||
@@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS]
|
||||
|
||||
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
|
||||
"""Get ElevenLabs model from their API by the model_id."""
|
||||
models = await client.models.get_all()
|
||||
models = await client.models.list()
|
||||
|
||||
for maybe_model in models:
|
||||
if maybe_model.model_id == model_id:
|
||||
return maybe_model
|
||||
|
||||
@@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry
|
||||
from .const import (
|
||||
CONF_CONFIGURE_VOICE,
|
||||
CONF_MODEL,
|
||||
CONF_OPTIMIZE_LATENCY,
|
||||
CONF_SIMILARITY,
|
||||
CONF_STABILITY,
|
||||
CONF_STYLE,
|
||||
CONF_USE_SPEAKER_BOOST,
|
||||
CONF_VOICE,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_OPTIMIZE_LATENCY,
|
||||
DEFAULT_SIMILARITY,
|
||||
DEFAULT_STABILITY,
|
||||
DEFAULT_STYLE,
|
||||
@@ -51,7 +49,8 @@ async def get_voices_models(
|
||||
httpx_client = get_async_client(hass)
|
||||
client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client)
|
||||
voices = (await client.voices.get_all()).voices
|
||||
models = await client.models.get_all()
|
||||
models = await client.models.list()
|
||||
|
||||
voices_dict = {
|
||||
voice.voice_id: voice.name
|
||||
for voice in sorted(voices, key=lambda v: v.name or "")
|
||||
@@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY])
|
||||
except ApiError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except ApiError as exc:
|
||||
errors["base"] = "unknown"
|
||||
details = getattr(exc, "body", {}).get("detail", {})
|
||||
if details:
|
||||
status = details.get("status")
|
||||
if status == "invalid_api_key":
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="ElevenLabs",
|
||||
@@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=0, max=1),
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_OPTIMIZE_LATENCY,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
|
||||
),
|
||||
): vol.All(int, vol.Range(min=0, max=4)),
|
||||
vol.Optional(
|
||||
CONF_STYLE,
|
||||
default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE),
|
||||
|
||||
@@ -7,7 +7,6 @@ CONF_MODEL = "model"
|
||||
CONF_CONFIGURE_VOICE = "configure_voice"
|
||||
CONF_STABILITY = "stability"
|
||||
CONF_SIMILARITY = "similarity"
|
||||
CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency"
|
||||
CONF_STYLE = "style"
|
||||
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
|
||||
DOMAIN = "elevenlabs"
|
||||
@@ -15,6 +14,5 @@ DOMAIN = "elevenlabs"
|
||||
DEFAULT_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_STABILITY = 0.5
|
||||
DEFAULT_SIMILARITY = 0.75
|
||||
DEFAULT_OPTIMIZE_LATENCY = 0
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["elevenlabs"],
|
||||
"requirements": ["elevenlabs==1.9.0"]
|
||||
"requirements": ["elevenlabs==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -32,14 +33,12 @@
|
||||
"data": {
|
||||
"stability": "Stability",
|
||||
"similarity": "Similarity",
|
||||
"optimize_streaming_latency": "Latency",
|
||||
"style": "Style",
|
||||
"use_speaker_boost": "Speaker boost"
|
||||
},
|
||||
"data_description": {
|
||||
"stability": "Stability of the generated audio. Higher values lead to less emotional audio.",
|
||||
"similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.",
|
||||
"optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.",
|
||||
"style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.",
|
||||
"use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice."
|
||||
}
|
||||
|
||||
@@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import ElevenLabsConfigEntry
|
||||
from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_OPTIMIZE_LATENCY,
|
||||
CONF_SIMILARITY,
|
||||
CONF_STABILITY,
|
||||
CONF_STYLE,
|
||||
CONF_USE_SPEAKER_BOOST,
|
||||
CONF_VOICE,
|
||||
DEFAULT_OPTIMIZE_LATENCY,
|
||||
DEFAULT_SIMILARITY,
|
||||
DEFAULT_STABILITY,
|
||||
DEFAULT_STYLE,
|
||||
@@ -75,9 +73,6 @@ async def async_setup_entry(
|
||||
config_entry.entry_id,
|
||||
config_entry.title,
|
||||
voice_settings,
|
||||
config_entry.options.get(
|
||||
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
entry_id: str,
|
||||
title: str,
|
||||
voice_settings: VoiceSettings,
|
||||
latency: int = 0,
|
||||
) -> None:
|
||||
"""Init ElevenLabs TTS service."""
|
||||
self._client = client
|
||||
@@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
if voice_indices:
|
||||
self._voices.insert(0, self._voices.pop(voice_indices[0]))
|
||||
self._voice_settings = voice_settings
|
||||
self._latency = latency
|
||||
|
||||
# Entity attributes
|
||||
self._attr_unique_id = entry_id
|
||||
@@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
voice_id = options.get(ATTR_VOICE, self._default_voice_id)
|
||||
model = options.get(ATTR_MODEL, self._model.model_id)
|
||||
try:
|
||||
audio = await self._client.generate(
|
||||
audio = self._client.text_to_speech.convert(
|
||||
text=message,
|
||||
voice=voice_id,
|
||||
optimize_streaming_latency=self._latency,
|
||||
voice_id=voice_id,
|
||||
voice_settings=self._voice_settings,
|
||||
model=model,
|
||||
model_id=model,
|
||||
)
|
||||
bytes_combined = b"".join([byte_seg async for byte_seg in audio])
|
||||
|
||||
except ApiError as exc:
|
||||
_LOGGER.warning(
|
||||
"Error during processing of TTS request %s", exc, exc_info=True
|
||||
|
||||
@@ -41,13 +41,8 @@ SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
}
|
||||
VALID_ENERGY_UNITS: set[str] = {
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_JOULE,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
}
|
||||
VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy)
|
||||
|
||||
VALID_ENERGY_UNITS_GAS = {
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
|
||||
@@ -21,14 +21,9 @@ from .const import DOMAIN
|
||||
|
||||
ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_JOULE,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
)
|
||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||
}
|
||||
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||
)
|
||||
@@ -39,13 +34,9 @@ GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
)
|
||||
GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_JOULE,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[
|
||||
sensor.SensorDeviceClass.ENERGY
|
||||
],
|
||||
sensor.SensorDeviceClass.GAS: (
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"]
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel(
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.DISARM, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.DISARM,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.ARM_HOME, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.ARM_HOME,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.ARM_AWAY, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.ARM_AWAY,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.ARM_NIGHT, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.ARM_NIGHT,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.ARM_CUSTOM_BYPASS,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.ARM_VACATION, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.ARM_VACATION,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send alarm trigger command."""
|
||||
self._client.alarm_control_panel_command(
|
||||
self._key, AlarmControlPanelCommand.TRIGGER, code
|
||||
self._key,
|
||||
AlarmControlPanelCommand.TRIGGER,
|
||||
code,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
self._client.button_command(self._key)
|
||||
self._client.button_command(self._key, device_id=self._static_info.device_id)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
|
||||
if ATTR_TARGET_TEMP_HIGH in kwargs:
|
||||
data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH]
|
||||
self._client.climate_command(**data)
|
||||
self._client.climate_command(**data, device_id=self._static_info.device_id)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
self._client.climate_command(key=self._key, target_humidity=humidity)
|
||||
self._client.climate_command(
|
||||
key=self._key,
|
||||
target_humidity=humidity,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target operation mode."""
|
||||
self._client.climate_command(
|
||||
key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode)
|
||||
key=self._key,
|
||||
mode=_CLIMATE_MODES.from_hass(hvac_mode),
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
@@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
kwargs["custom_preset"] = preset_mode
|
||||
else:
|
||||
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
|
||||
self._client.climate_command(**kwargs)
|
||||
self._client.climate_command(**kwargs, device_id=self._static_info.device_id)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
@@ -319,13 +325,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
kwargs["custom_fan_mode"] = fan_mode
|
||||
else:
|
||||
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
|
||||
self._client.climate_command(**kwargs)
|
||||
self._client.climate_command(**kwargs, device_id=self._static_info.device_id)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new swing mode."""
|
||||
self._client.climate_command(
|
||||
key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode)
|
||||
key=self._key,
|
||||
swing_mode=_SWING_MODES.from_hass(swing_mode),
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
self._client.cover_command(key=self._key, position=1.0)
|
||||
self._client.cover_command(
|
||||
key=self._key, position=1.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
self._client.cover_command(key=self._key, position=0.0)
|
||||
self._client.cover_command(
|
||||
key=self._key, position=0.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
self._client.cover_command(key=self._key, stop=True)
|
||||
self._client.cover_command(
|
||||
key=self._key, stop=True, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100)
|
||||
self._client.cover_command(
|
||||
key=self._key,
|
||||
position=kwargs[ATTR_POSITION] / 100,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
self._client.cover_command(key=self._key, tilt=1.0)
|
||||
self._client.cover_command(
|
||||
key=self._key, tilt=1.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
self._client.cover_command(key=self._key, tilt=0.0)
|
||||
self._client.cover_command(
|
||||
key=self._key, tilt=0.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
tilt_position: int = kwargs[ATTR_TILT_POSITION]
|
||||
self._client.cover_command(key=self._key, tilt=tilt_position / 100)
|
||||
self._client.cover_command(
|
||||
key=self._key,
|
||||
tilt=tilt_position / 100,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity):
|
||||
|
||||
async def async_set_value(self, value: date) -> None:
|
||||
"""Update the current date."""
|
||||
self._client.date_command(self._key, value.year, value.month, value.day)
|
||||
self._client.date_command(
|
||||
self._key,
|
||||
value.year,
|
||||
value.month,
|
||||
value.day,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity
|
||||
|
||||
async def async_set_value(self, value: datetime) -> None:
|
||||
"""Update the current datetime."""
|
||||
self._client.datetime_command(self._key, int(value.timestamp()))
|
||||
self._client.datetime_command(
|
||||
self._key, int(value.timestamp()), device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
ORDERED_NAMED_FAN_SPEEDS, percentage
|
||||
)
|
||||
data["speed"] = named_speed
|
||||
self._client.fan_command(**data)
|
||||
self._client.fan_command(**data, device_id=self._static_info.device_id)
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
@@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
self._client.fan_command(key=self._key, state=False)
|
||||
self._client.fan_command(
|
||||
key=self._key, state=False, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
self._client.fan_command(key=self._key, oscillating=oscillating)
|
||||
self._client.fan_command(
|
||||
key=self._key,
|
||||
oscillating=oscillating,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set direction of the fan."""
|
||||
self._client.fan_command(
|
||||
key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction)
|
||||
key=self._key,
|
||||
direction=_FAN_DIRECTIONS.from_hass(direction),
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
self._client.fan_command(key=self._key, preset_mode=preset_mode)
|
||||
self._client.fan_command(
|
||||
key=self._key,
|
||||
preset_mode=preset_mode,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
|
||||
@@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
# (fewest capabilities set)
|
||||
data["color_mode"] = _least_complex_color_mode(color_modes)
|
||||
|
||||
self._client.light_command(**data)
|
||||
self._client.light_command(**data, device_id=self._static_info.device_id)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -290,7 +290,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
data["transition_length"] = kwargs[ATTR_TRANSITION]
|
||||
self._client.light_command(**data)
|
||||
self._client.light_command(**data, device_id=self._static_info.device_id)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
|
||||
@@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the lock."""
|
||||
self._client.lock_command(self._key, LockCommand.LOCK)
|
||||
self._client.lock_command(
|
||||
self._key, LockCommand.LOCK, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the lock."""
|
||||
code = kwargs.get(ATTR_CODE)
|
||||
self._client.lock_command(self._key, LockCommand.UNLOCK, code)
|
||||
self._client.lock_command(
|
||||
self._key, LockCommand.UNLOCK, code, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Open the door latch."""
|
||||
self._client.lock_command(self._key, LockCommand.OPEN)
|
||||
self._client.lock_command(
|
||||
self._key, LockCommand.OPEN, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==34.2.0",
|
||||
"aioesphomeapi==35.0.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==2.16.0"
|
||||
"bleak-esphome==3.1.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ class EsphomeMediaPlayer(
|
||||
media_id = proxy_url
|
||||
|
||||
self._client.media_player_command(
|
||||
self._key, media_url=media_id, announcement=announcement
|
||||
self._key,
|
||||
media_url=media_id,
|
||||
announcement=announcement,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
@@ -214,22 +217,36 @@ class EsphomeMediaPlayer(
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self._client.media_player_command(self._key, volume=volume)
|
||||
self._client.media_player_command(
|
||||
self._key, volume=volume, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE)
|
||||
self._client.media_player_command(
|
||||
self._key,
|
||||
command=MediaPlayerCommand.PAUSE,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY)
|
||||
self._client.media_player_command(
|
||||
self._key,
|
||||
command=MediaPlayerCommand.PLAY,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP)
|
||||
self._client.media_player_command(
|
||||
self._key,
|
||||
command=MediaPlayerCommand.STOP,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
@@ -237,6 +254,7 @@ class EsphomeMediaPlayer(
|
||||
self._client.media_player_command(
|
||||
self._key,
|
||||
command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
self._client.number_command(self._key, value)
|
||||
self._client.number_command(
|
||||
self._key, value, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
self._client.select_command(self._key, option)
|
||||
self._client.select_command(
|
||||
self._key, option, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect):
|
||||
|
||||
@@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
self._client.switch_command(self._key, True)
|
||||
self._client.switch_command(
|
||||
self._key, True, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
self._client.switch_command(self._key, False)
|
||||
self._client.switch_command(
|
||||
self._key, False, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Update the current value."""
|
||||
self._client.text_command(self._key, value)
|
||||
self._client.text_command(
|
||||
self._key, value, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity):
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Update the current time."""
|
||||
self._client.time_command(self._key, value.hour, value.minute, value.second)
|
||||
self._client.time_command(
|
||||
self._key,
|
||||
value.hour,
|
||||
value.minute,
|
||||
value.second,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
async def async_update(self) -> None:
|
||||
"""Command device to check for update."""
|
||||
if self.available:
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.CHECK)
|
||||
self._client.update_command(
|
||||
key=self._key,
|
||||
command=UpdateCommand.CHECK,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Command device to install update."""
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.INSTALL)
|
||||
self._client.update_command(
|
||||
key=self._key,
|
||||
command=UpdateCommand.INSTALL,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity):
|
||||
@convert_api_error_ha_error
|
||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||
"""Open the valve."""
|
||||
self._client.valve_command(key=self._key, position=1.0)
|
||||
self._client.valve_command(
|
||||
key=self._key, position=1.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_close_valve(self, **kwargs: Any) -> None:
|
||||
"""Close valve."""
|
||||
self._client.valve_command(key=self._key, position=0.0)
|
||||
self._client.valve_command(
|
||||
key=self._key, position=0.0, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_stop_valve(self, **kwargs: Any) -> None:
|
||||
"""Stop the valve."""
|
||||
self._client.valve_command(key=self._key, stop=True)
|
||||
self._client.valve_command(
|
||||
key=self._key, stop=True, device_id=self._static_info.device_id
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_valve_position(self, position: float) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
self._client.valve_command(key=self._key, position=position / 100)
|
||||
self._client.valve_command(
|
||||
key=self._key,
|
||||
position=position / 100,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
for device in new_data.devices.values():
|
||||
# create device registry entry for new main devices
|
||||
if (
|
||||
device.ain not in self.data.devices
|
||||
and device.device_and_unit_id[1] is None
|
||||
if device.ain not in self.data.devices and (
|
||||
device.device_and_unit_id[1] is None
|
||||
or (
|
||||
# workaround for sub units without a main device, e.g. Energy 250
|
||||
# https://github.com/home-assistant/core/issues/145204
|
||||
device.device_and_unit_id[1] == "1"
|
||||
and device.device_and_unit_id[0] not in new_data.devices
|
||||
)
|
||||
):
|
||||
dr.async_get(self.hass).async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
name=device.name,
|
||||
identifiers={(DOMAIN, device.ain)},
|
||||
identifiers={(DOMAIN, device.device_and_unit_id[0])},
|
||||
manufacturer=device.manufacturer,
|
||||
model=device.productname,
|
||||
sw_version=device.fw_version,
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.const import (
|
||||
EVENT_THEMES_UPDATED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.icon import async_get_icons
|
||||
from homeassistant.helpers.json import json_dumps_sorted
|
||||
@@ -543,6 +544,12 @@ async def _async_setup_themes(
|
||||
"""Reload themes."""
|
||||
config = await async_hass_config_yaml(hass)
|
||||
new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {})
|
||||
|
||||
try:
|
||||
THEME_SCHEMA(new_themes)
|
||||
except vol.Invalid as err:
|
||||
raise HomeAssistantError(f"Failed to reload themes: {err}") from err
|
||||
|
||||
hass.data[DATA_THEMES] = new_themes
|
||||
if hass.data[DATA_DEFAULT_THEME] not in new_themes:
|
||||
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250702.1"]
|
||||
"requirements": ["home-assistant-frontend==20250702.2"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""The generic_hygrostat component."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.humidifier import HumidifierDeviceClass
|
||||
@@ -16,7 +18,10 @@ from homeassistant.helpers.device import (
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "generic_hygrostat"
|
||||
@@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Generic Hygrostat component."""
|
||||
@@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
@@ -101,23 +109,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
|
||||
)
|
||||
|
||||
async def source_entity_removed() -> None:
|
||||
# The source entity has been removed, we need to clean the device links.
|
||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
||||
|
||||
entry.async_on_unload(
|
||||
# We use async_handle_source_entity_changes to track changes to the humidifer,
|
||||
# but not the humidity sensor because the generic_hygrostat adds itself to the
|
||||
# humidifier's device.
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
hass, entry.options[CONF_HUMIDIFIER]
|
||||
),
|
||||
source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER],
|
||||
source_entity_removed=source_entity_removed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -148,6 +152,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
if config_entry.version == 1:
|
||||
options = {**config_entry.options}
|
||||
if config_entry.minor_version < 2:
|
||||
# Remove the generic_hygrostat config entry from the source device
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_HUMIDIFIER]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update listener, called when the config entry options are changed."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -92,6 +92,8 @@ OPTIONS_FLOW = {
|
||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config or options flow."""
|
||||
|
||||
MINOR_VERSION = 2
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -145,22 +145,22 @@ async def _async_setup_config(
|
||||
[
|
||||
GenericHygrostat(
|
||||
hass,
|
||||
name,
|
||||
switch_entity_id,
|
||||
sensor_entity_id,
|
||||
min_humidity,
|
||||
max_humidity,
|
||||
target_humidity,
|
||||
device_class,
|
||||
min_cycle_duration,
|
||||
dry_tolerance,
|
||||
wet_tolerance,
|
||||
keep_alive,
|
||||
initial_state,
|
||||
away_humidity,
|
||||
away_fixed,
|
||||
sensor_stale_duration,
|
||||
unique_id,
|
||||
name=name,
|
||||
switch_entity_id=switch_entity_id,
|
||||
sensor_entity_id=sensor_entity_id,
|
||||
min_humidity=min_humidity,
|
||||
max_humidity=max_humidity,
|
||||
target_humidity=target_humidity,
|
||||
device_class=device_class,
|
||||
min_cycle_duration=min_cycle_duration,
|
||||
dry_tolerance=dry_tolerance,
|
||||
wet_tolerance=wet_tolerance,
|
||||
keep_alive=keep_alive,
|
||||
initial_state=initial_state,
|
||||
away_humidity=away_humidity,
|
||||
away_fixed=away_fixed,
|
||||
sensor_stale_duration=sensor_stale_duration,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
name: str,
|
||||
switch_entity_id: str,
|
||||
sensor_entity_id: str,
|
||||
@@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
|
||||
self._name = name
|
||||
self._switch_entity_id = switch_entity_id
|
||||
self._sensor_entity_id = sensor_entity_id
|
||||
self._attr_device_info = async_device_info_to_link_from_entity(
|
||||
self.device_entry = async_entity_id_to_device(
|
||||
hass,
|
||||
switch_entity_id,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""The generic_thermostat component."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -8,14 +10,20 @@ from homeassistant.helpers.device import (
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
|
||||
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
@@ -28,23 +36,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options={**entry.options, CONF_HEATER: source_entity_id},
|
||||
)
|
||||
|
||||
async def source_entity_removed() -> None:
|
||||
# The source entity has been removed, we need to clean the device links.
|
||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
||||
|
||||
entry.async_on_unload(
|
||||
# We use async_handle_source_entity_changes to track changes to the heater, but
|
||||
# not the temperature sensor because the generic_hygrostat adds itself to the
|
||||
# heater's device.
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
hass, entry.options[CONF_HEATER]
|
||||
),
|
||||
source_entity_id_or_uuid=entry.options[CONF_HEATER],
|
||||
source_entity_removed=source_entity_removed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -75,6 +79,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
if config_entry.version == 1:
|
||||
options = {**config_entry.options}
|
||||
if config_entry.minor_version < 2:
|
||||
# Remove the generic_thermostat config entry from the source device
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_HEATER]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update listener, called when the config entry options are changed."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -48,7 +48,7 @@ from homeassistant.core import (
|
||||
)
|
||||
from homeassistant.exceptions import ConditionError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -182,23 +182,23 @@ async def _async_setup_config(
|
||||
[
|
||||
GenericThermostat(
|
||||
hass,
|
||||
name,
|
||||
heater_entity_id,
|
||||
sensor_entity_id,
|
||||
min_temp,
|
||||
max_temp,
|
||||
target_temp,
|
||||
ac_mode,
|
||||
min_cycle_duration,
|
||||
cold_tolerance,
|
||||
hot_tolerance,
|
||||
keep_alive,
|
||||
initial_hvac_mode,
|
||||
presets,
|
||||
precision,
|
||||
target_temperature_step,
|
||||
unit,
|
||||
unique_id,
|
||||
name=name,
|
||||
heater_entity_id=heater_entity_id,
|
||||
sensor_entity_id=sensor_entity_id,
|
||||
min_temp=min_temp,
|
||||
max_temp=max_temp,
|
||||
target_temp=target_temp,
|
||||
ac_mode=ac_mode,
|
||||
min_cycle_duration=min_cycle_duration,
|
||||
cold_tolerance=cold_tolerance,
|
||||
hot_tolerance=hot_tolerance,
|
||||
keep_alive=keep_alive,
|
||||
initial_hvac_mode=initial_hvac_mode,
|
||||
presets=presets,
|
||||
precision=precision,
|
||||
target_temperature_step=target_temperature_step,
|
||||
unit=unit,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
name: str,
|
||||
heater_entity_id: str,
|
||||
sensor_entity_id: str,
|
||||
@@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self._attr_name = name
|
||||
self.heater_entity_id = heater_entity_id
|
||||
self.sensor_entity_id = sensor_entity_id
|
||||
self._attr_device_info = async_device_info_to_link_from_entity(
|
||||
self.device_entry = async_entity_id_to_device(
|
||||
hass,
|
||||
heater_entity_id,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user