mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
Merge branch 'dev' of github.com:home-assistant/core into target_trigger
This commit is contained in:
commit
ee2c7cacfa
@ -8,6 +8,7 @@
|
||||
"PYTHONASYNCIODEBUG": "1"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
// Port 5683 udp is used by Shelly integration
|
||||
|
6
.github/ISSUE_TEMPLATE/task.yml
vendored
6
.github/ISSUE_TEMPLATE/task.yml
vendored
@ -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
|
||||
|
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@ -1149,7 +1149,7 @@ _LOGGER.debug("Processing data: %s", data) # Use lazy logging
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Check specific integration
|
||||
python -m script.hassfest --integration my_integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/my_integration
|
||||
|
||||
# Validate quality scale
|
||||
# Check quality_scale.yaml against current rules
|
||||
|
@ -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",
|
||||
|
@ -79,7 +79,9 @@ 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
|
||||
|
||||
|
@ -10,6 +10,7 @@ generate_data:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
entity_id:
|
||||
required: false
|
||||
selector:
|
||||
|
@ -2,30 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components import conversation, media_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
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}>"
|
||||
|
||||
|
||||
async def async_generate_data(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
@ -52,7 +40,7 @@ async def async_generate_data(
|
||||
)
|
||||
|
||||
# Resolve attachments
|
||||
resolved_attachments: list[PlayMediaWithId] | None = None
|
||||
resolved_attachments: list[conversation.Attachment] | None = None
|
||||
|
||||
if attachments:
|
||||
if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features:
|
||||
@ -66,13 +54,16 @@ async def async_generate_data(
|
||||
media = await media_source.async_resolve_media(
|
||||
hass, attachment["media_content_id"], None
|
||||
)
|
||||
if media.path is None:
|
||||
raise HomeAssistantError(
|
||||
"Only local attachments are currently supported"
|
||||
)
|
||||
resolved_attachments.append(
|
||||
PlayMediaWithId(
|
||||
**{
|
||||
field.name: getattr(media, field.name)
|
||||
for field in fields(media)
|
||||
},
|
||||
conversation.Attachment(
|
||||
media_content_id=attachment["media_content_id"],
|
||||
url=media.url,
|
||||
mime_type=media.mime_type,
|
||||
path=media.path,
|
||||
)
|
||||
)
|
||||
|
||||
@ -99,7 +90,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"
|
||||
},
|
||||
|
@ -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}"
|
||||
)
|
||||
|
@ -6,7 +6,12 @@ from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
|
||||
from aioamazondevices.exceptions import (
|
||||
CannotAuthenticate,
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
WrongCountry,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@ -57,6 +62,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
except WrongCountry:
|
||||
errors["base"] = "wrong_country"
|
||||
else:
|
||||
@ -106,6 +113,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
|
@ -52,8 +52,18 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
try:
|
||||
await self.api.login_mode_stored_data()
|
||||
return await self.api.get_devices_data()
|
||||
except (CannotConnect, CannotRetrieveData) as err:
|
||||
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
|
||||
except CannotConnect as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.2.3"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==3.2.10"]
|
||||
}
|
||||
|
@ -28,33 +28,31 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: all tests missing
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Network information not relevant
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
@ -43,6 +43,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
@ -84,10 +85,10 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"cannot_connect_with_error": {
|
||||
"message": "Error connecting: {error}"
|
||||
},
|
||||
"cannot_retrieve_data": {
|
||||
"cannot_retrieve_data_with_error": {
|
||||
"message": "Error retrieving data: {error}"
|
||||
}
|
||||
}
|
||||
|
@ -26,14 +26,14 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data",
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
110
homeassistant/components/bring/services.py
Normal file
110
homeassistant/components/bring/services.py
Normal file
@ -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.",
|
||||
|
@ -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.106.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from hass_nabucasa.payments_api import SubscriptionInfo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import (
|
||||
@ -26,7 +26,7 @@ MAX_RETRIES = 60 # This allows for 10 minutes of retries
|
||||
@callback
|
||||
def async_manage_legacy_subscription_issue(
|
||||
hass: HomeAssistant,
|
||||
subscription_info: dict[str, Any],
|
||||
subscription_info: SubscriptionInfo,
|
||||
) -> None:
|
||||
"""Manage the legacy subscription issue.
|
||||
|
||||
@ -50,7 +50,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
wait_task: asyncio.Task | None = None
|
||||
_data: dict[str, Any] | None = None
|
||||
_data: SubscriptionInfo | None = None
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
|
@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from hass_nabucasa import Cloud, cloud_api
|
||||
from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo
|
||||
|
||||
from .client import CloudClient
|
||||
from .const import REQUEST_TIMEOUT
|
||||
@ -15,21 +16,13 @@ from .const import REQUEST_TIMEOUT
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None:
|
||||
async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo | None:
|
||||
"""Fetch the subscription info."""
|
||||
try:
|
||||
async with asyncio.timeout(REQUEST_TIMEOUT):
|
||||
return await cloud_api.async_subscription_info(cloud)
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
(
|
||||
"A timeout of %s was reached while trying to fetch subscription"
|
||||
" information"
|
||||
),
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
except ClientError:
|
||||
_LOGGER.error("Failed to fetch subscription information")
|
||||
return await cloud.payments.subscription_info()
|
||||
except PaymentsApiError as exception:
|
||||
_LOGGER.error("Failed to fetch subscription information - %s", exception)
|
||||
|
||||
return None
|
||||
|
||||
|
@ -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,24 @@ 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."""
|
||||
|
||||
url: str
|
||||
"""URL 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
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -179,6 +179,47 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure the entry."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {}
|
||||
reconfig_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_URL]
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
emoncms_client = EmoncmsClient(
|
||||
url, api_key, session=async_get_clientsession(self.hass)
|
||||
)
|
||||
result = await get_feed_list(emoncms_client)
|
||||
if not result[CONF_SUCCESS]:
|
||||
errors["base"] = "api_error"
|
||||
description_placeholders = {"details": result[CONF_MESSAGE]}
|
||||
else:
|
||||
await self.async_set_unique_id(await emoncms_client.async_get_uuid())
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfig_entry,
|
||||
title=sensor_name(url),
|
||||
data=user_input,
|
||||
reload_even_if_entry_is_unchanged=False,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
user_input or reconfig_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
|
||||
class EmoncmsOptionsFlow(OptionsFlow):
|
||||
"""Emoncms Options flow handler."""
|
||||
|
@ -22,7 +22,9 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured"
|
||||
"already_configured": "This server is already configured",
|
||||
"unique_id_mismatch": "This emoncms serial number does not match the previous serial number",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
@ -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(
|
||||
|
@ -33,7 +33,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
|
||||
from .entry_data import (
|
||||
DeviceEntityKey,
|
||||
ESPHomeConfigEntry,
|
||||
RuntimeEntryData,
|
||||
build_device_unique_id,
|
||||
)
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -59,17 +64,32 @@ def async_static_info_updated(
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
new_infos: dict[int, EntityInfo] = {}
|
||||
new_infos: dict[DeviceEntityKey, EntityInfo] = {}
|
||||
add_entities: list[_EntityT] = []
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
# Track info by (info.device_id, info.key) to properly handle entities
|
||||
# moving between devices and support sub-devices with overlapping keys
|
||||
for info in infos:
|
||||
new_infos[info.key] = info
|
||||
info_key = (info.device_id, info.key)
|
||||
new_infos[info_key] = info
|
||||
|
||||
# Try to find existing entity - first with current device_id
|
||||
old_info = current_infos.pop(info_key, None)
|
||||
|
||||
# If not found, search for entity with same key but different device_id
|
||||
# This handles the case where entity moved between devices
|
||||
if not old_info:
|
||||
for existing_device_id, existing_key in list(current_infos):
|
||||
if existing_key == info.key:
|
||||
# Found entity with same key but different device_id
|
||||
old_info = current_infos.pop((existing_device_id, existing_key))
|
||||
break
|
||||
|
||||
# Create new entity if it doesn't exist
|
||||
if not (old_info := current_infos.pop(info.key, None)):
|
||||
if not old_info:
|
||||
entity = entity_type(entry_data, platform.domain, info, state_type)
|
||||
add_entities.append(entity)
|
||||
continue
|
||||
@ -78,7 +98,7 @@ def async_static_info_updated(
|
||||
if old_info.device_id == info.device_id:
|
||||
continue
|
||||
|
||||
# Entity has switched devices, need to migrate unique_id
|
||||
# Entity has switched devices, need to migrate unique_id and handle state subscriptions
|
||||
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
|
||||
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
|
||||
|
||||
@ -103,7 +123,7 @@ def async_static_info_updated(
|
||||
if old_unique_id != new_unique_id:
|
||||
updates["new_unique_id"] = new_unique_id
|
||||
|
||||
# Update device assignment
|
||||
# Update device assignment in registry
|
||||
if info.device_id:
|
||||
# Entity now belongs to a sub device
|
||||
new_device = dev_reg.async_get_device(
|
||||
@ -118,10 +138,32 @@ def async_static_info_updated(
|
||||
if new_device:
|
||||
updates["device_id"] = new_device.id
|
||||
|
||||
# Apply all updates at once
|
||||
# Apply all registry updates at once
|
||||
if updates:
|
||||
ent_reg.async_update_entity(entity_id, **updates)
|
||||
|
||||
# IMPORTANT: The entity's device assignment in Home Assistant is only read when the entity
|
||||
# is first added. Updating the registry alone won't move the entity to the new device
|
||||
# in the UI. Additionally, the entity's state subscription is tied to the old device_id,
|
||||
# so it won't receive state updates for the new device_id.
|
||||
#
|
||||
# We must remove the old entity and re-add it to ensure:
|
||||
# 1. The entity appears under the correct device in the UI
|
||||
# 2. The entity's state subscription is updated to use the new device_id
|
||||
_LOGGER.debug(
|
||||
"Entity %s moving from device_id %s to %s",
|
||||
info.key,
|
||||
old_info.device_id,
|
||||
info.device_id,
|
||||
)
|
||||
|
||||
# Signal the existing entity to remove itself
|
||||
# The entity is registered with the old device_id, so we signal with that
|
||||
entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key)
|
||||
|
||||
# Create new entity with the new device_id
|
||||
add_entities.append(entity_type(entry_data, platform.domain, info, state_type))
|
||||
|
||||
# Anything still in current_infos is now gone
|
||||
if current_infos:
|
||||
entry_data.async_remove_entities(
|
||||
@ -341,7 +383,10 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
)
|
||||
self.async_on_remove(
|
||||
entry_data.async_subscribe_state_update(
|
||||
self._state_type, self._key, self._on_state_update
|
||||
self._static_info.device_id,
|
||||
self._state_type,
|
||||
self._key,
|
||||
self._on_state_update,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
@ -349,8 +394,29 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
self._static_info, self._on_static_info_update
|
||||
)
|
||||
)
|
||||
# Register to be notified when this entity should remove itself
|
||||
# This happens when the entity moves to a different device
|
||||
self.async_on_remove(
|
||||
entry_data.async_register_entity_removal_callback(
|
||||
type(self._static_info),
|
||||
self._static_info.device_id,
|
||||
self._key,
|
||||
self._on_removal_signal,
|
||||
)
|
||||
)
|
||||
self._update_state_from_entry_data()
|
||||
|
||||
@callback
|
||||
def _on_removal_signal(self) -> None:
|
||||
"""Handle signal to remove this entity."""
|
||||
_LOGGER.debug(
|
||||
"Entity %s received removal signal due to device_id change",
|
||||
self.entity_id,
|
||||
)
|
||||
# Schedule the entity to be removed
|
||||
# This must be done as a task since we're in a callback
|
||||
self.hass.async_create_task(self.async_remove())
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Save the static info for this entity when it changes.
|
||||
|
@ -60,7 +60,9 @@ from .const import DOMAIN
|
||||
from .dashboard import async_get_dashboard
|
||||
|
||||
type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData]
|
||||
|
||||
type EntityStateKey = tuple[type[EntityState], int, int] # (state_type, device_id, key)
|
||||
type EntityInfoKey = tuple[type[EntityInfo], int, int] # (info_type, device_id, key)
|
||||
type DeviceEntityKey = tuple[int, int] # (device_id, key)
|
||||
|
||||
INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()}
|
||||
|
||||
@ -137,8 +139,10 @@ class RuntimeEntryData:
|
||||
# When the disconnect callback is called, we mark all states
|
||||
# as stale so we will always dispatch a state update when the
|
||||
# device reconnects. This is the same format as state_subscriptions.
|
||||
stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set)
|
||||
info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict)
|
||||
stale_state: set[EntityStateKey] = field(default_factory=set)
|
||||
info: dict[type[EntityInfo], dict[DeviceEntityKey, EntityInfo]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
services: dict[int, UserService] = field(default_factory=dict)
|
||||
available: bool = False
|
||||
expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep)
|
||||
@ -147,7 +151,7 @@ class RuntimeEntryData:
|
||||
api_version: APIVersion = field(default_factory=APIVersion)
|
||||
cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
|
||||
disconnect_callbacks: set[CALLBACK_TYPE] = field(default_factory=set)
|
||||
state_subscriptions: dict[tuple[type[EntityState], int], CALLBACK_TYPE] = field(
|
||||
state_subscriptions: dict[EntityStateKey, CALLBACK_TYPE] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set)
|
||||
@ -164,7 +168,7 @@ class RuntimeEntryData:
|
||||
type[EntityInfo], list[Callable[[list[EntityInfo]], None]]
|
||||
] = field(default_factory=dict)
|
||||
entity_info_key_updated_callbacks: dict[
|
||||
tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]]
|
||||
EntityInfoKey, list[Callable[[EntityInfo], None]]
|
||||
] = field(default_factory=dict)
|
||||
original_options: dict[str, Any] = field(default_factory=dict)
|
||||
media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
|
||||
@ -177,6 +181,9 @@ class RuntimeEntryData:
|
||||
default_factory=list
|
||||
)
|
||||
device_id_to_name: dict[int, str] = field(default_factory=dict)
|
||||
entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -210,7 +217,7 @@ class RuntimeEntryData:
|
||||
callback_: Callable[[EntityInfo], None],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register to receive callbacks when static info is updated for a specific key."""
|
||||
callback_key = (type(static_info), static_info.key)
|
||||
callback_key = (type(static_info), static_info.device_id, static_info.key)
|
||||
callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, [])
|
||||
callbacks.append(callback_)
|
||||
return partial(callbacks.remove, callback_)
|
||||
@ -250,7 +257,9 @@ class RuntimeEntryData:
|
||||
"""Call static info updated callbacks."""
|
||||
callbacks = self.entity_info_key_updated_callbacks
|
||||
for static_info in static_infos:
|
||||
for callback_ in callbacks.get((type(static_info), static_info.key), ()):
|
||||
for callback_ in callbacks.get(
|
||||
(type(static_info), static_info.device_id, static_info.key), ()
|
||||
):
|
||||
callback_(static_info)
|
||||
|
||||
async def _ensure_platforms_loaded(
|
||||
@ -342,12 +351,13 @@ class RuntimeEntryData:
|
||||
@callback
|
||||
def async_subscribe_state_update(
|
||||
self,
|
||||
device_id: int,
|
||||
state_type: type[EntityState],
|
||||
state_key: int,
|
||||
entity_callback: CALLBACK_TYPE,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Subscribe to state updates."""
|
||||
subscription_key = (state_type, state_key)
|
||||
subscription_key = (state_type, device_id, state_key)
|
||||
self.state_subscriptions[subscription_key] = entity_callback
|
||||
return partial(delitem, self.state_subscriptions, subscription_key)
|
||||
|
||||
@ -359,7 +369,7 @@ class RuntimeEntryData:
|
||||
stale_state = self.stale_state
|
||||
current_state_by_type = self.state[state_type]
|
||||
current_state = current_state_by_type.get(key, _SENTINEL)
|
||||
subscription_key = (state_type, key)
|
||||
subscription_key = (state_type, state.device_id, key)
|
||||
if (
|
||||
current_state == state
|
||||
and subscription_key not in stale_state
|
||||
@ -367,7 +377,7 @@ class RuntimeEntryData:
|
||||
and not (
|
||||
state_type is SensorState
|
||||
and (platform_info := self.info.get(SensorInfo))
|
||||
and (entity_info := platform_info.get(state.key))
|
||||
and (entity_info := platform_info.get((state.device_id, state.key)))
|
||||
and (cast(SensorInfo, entity_info)).force_update
|
||||
)
|
||||
):
|
||||
@ -520,3 +530,26 @@ class RuntimeEntryData:
|
||||
"""Notify listeners that the Assist satellite wake word has been set."""
|
||||
for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
|
||||
callback_(wake_word_id)
|
||||
|
||||
@callback
|
||||
def async_register_entity_removal_callback(
|
||||
self,
|
||||
info_type: type[EntityInfo],
|
||||
device_id: int,
|
||||
key: int,
|
||||
callback_: CALLBACK_TYPE,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register to receive a callback when the entity should remove itself."""
|
||||
callback_key = (info_type, device_id, key)
|
||||
callbacks = self.entity_removal_callbacks.setdefault(callback_key, [])
|
||||
callbacks.append(callback_)
|
||||
return partial(callbacks.remove, callback_)
|
||||
|
||||
@callback
|
||||
def async_signal_entity_removal(
|
||||
self, info_type: type[EntityInfo], device_id: int, key: int
|
||||
) -> None:
|
||||
"""Signal that an entity should remove itself."""
|
||||
callback_key = (info_type, device_id, key)
|
||||
for callback_ in self.entity_removal_callbacks.get(callback_key, []).copy():
|
||||
callback_()
|
||||
|
@ -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(
|
||||
|
@ -588,7 +588,7 @@ class ESPHomeManager:
|
||||
# Mark state as stale so that we will always dispatch
|
||||
# the next state update of that type when the device reconnects
|
||||
entry_data.stale_state = {
|
||||
(type(entity_state), key)
|
||||
(type(entity_state), entity_state.device_id, key)
|
||||
for state_dict in entry_data.state.values()
|
||||
for key, entity_state in state_dict.items()
|
||||
}
|
||||
|
@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==34.1.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,
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@ -74,6 +75,7 @@ async def async_setup_entry(
|
||||
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=name,
|
||||
sw_version=sw_version,
|
||||
manufacturer=manufacturer,
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -100,6 +100,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
|
||||
|
||||
|
@ -7,6 +7,7 @@ from typing import Final
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.zone import condition as zone_condition
|
||||
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
@ -17,7 +18,7 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_validation import entity_domain
|
||||
from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
@ -79,9 +80,11 @@ async def async_attach_trigger(
|
||||
return
|
||||
|
||||
from_match = (
|
||||
condition.zone(hass, zone_state, from_state) if from_state else False
|
||||
zone_condition.zone(hass, zone_state, from_state) if from_state else False
|
||||
)
|
||||
to_match = (
|
||||
zone_condition.zone(hass, zone_state, to_state) if to_state else False
|
||||
)
|
||||
to_match = condition.zone(hass, zone_state, to_state) if to_state else False
|
||||
|
||||
if (trigger_event == EVENT_ENTER and not from_match and to_match) or (
|
||||
trigger_event == EVENT_LEAVE and from_match and not to_match
|
||||
|
@ -19,6 +19,8 @@ API_TIMEOUT: Final = 30
|
||||
|
||||
ATTR_C6H6: Final = "c6h6"
|
||||
ATTR_CO: Final = "co"
|
||||
ATTR_NO: Final = "no"
|
||||
ATTR_NOX: Final = "nox"
|
||||
ATTR_NO2: Final = "no2"
|
||||
ATTR_O3: Final = "o3"
|
||||
ATTR_PM10: Final = "pm10"
|
||||
|
@ -13,6 +13,9 @@
|
||||
"no2_index": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"nox": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"o3_index": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["dacite", "gios"],
|
||||
"requirements": ["gios==6.1.0"]
|
||||
"requirements": ["gios==6.1.1"]
|
||||
}
|
||||
|
@ -27,7 +27,9 @@ from .const import (
|
||||
ATTR_AQI,
|
||||
ATTR_C6H6,
|
||||
ATTR_CO,
|
||||
ATTR_NO,
|
||||
ATTR_NO2,
|
||||
ATTR_NOX,
|
||||
ATTR_O3,
|
||||
ATTR_PM10,
|
||||
ATTR_PM25,
|
||||
@ -74,6 +76,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="co",
|
||||
),
|
||||
GiosSensorEntityDescription(
|
||||
key=ATTR_NO,
|
||||
value=lambda sensors: sensors.no.value if sensors.no else None,
|
||||
suggested_display_precision=0,
|
||||
device_class=SensorDeviceClass.NITROGEN_MONOXIDE,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GiosSensorEntityDescription(
|
||||
key=ATTR_NO2,
|
||||
value=lambda sensors: sensors.no2.value if sensors.no2 else None,
|
||||
@ -90,6 +100,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = (
|
||||
options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"],
|
||||
translation_key="no2_index",
|
||||
),
|
||||
GiosSensorEntityDescription(
|
||||
key=ATTR_NOX,
|
||||
translation_key=ATTR_NOX,
|
||||
value=lambda sensors: sensors.nox.value if sensors.nox else None,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GiosSensorEntityDescription(
|
||||
key=ATTR_O3,
|
||||
value=lambda sensors: sensors.o3.value if sensors.o3 else None,
|
||||
|
@ -77,6 +77,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nox": {
|
||||
"name": "Nitrogen oxides"
|
||||
},
|
||||
"o3_index": {
|
||||
"name": "Ozone index",
|
||||
"state": {
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""The Goodwe inverter component."""
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
@ -20,11 +21,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_UDP_PORT,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
except InverterError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except InverterError as err_udp:
|
||||
# First try with UDP failed, trying with the TCP port
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_TCP_PORT,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
except InverterError:
|
||||
# Both ports are unavailable
|
||||
raise ConfigEntryNotReady from err_udp
|
||||
|
||||
device_info = DeviceInfo(
|
||||
configuration_url="https://www.semsportal.com",
|
||||
|
@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@ -27,6 +28,18 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _handle_successful_connection(self, inverter, host):
|
||||
await self.async_set_unique_id(inverter.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_NAME,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@ -34,22 +47,19 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
|
||||
try:
|
||||
inverter = await connect(host=host, retries=10)
|
||||
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
|
||||
except InverterError:
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host, port=GOODWE_TCP_PORT, retries=10
|
||||
)
|
||||
except InverterError:
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
else:
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
else:
|
||||
await self.async_set_unique_id(inverter.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_NAME,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
},
|
||||
)
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/goodwe",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["goodwe"],
|
||||
"requirements": ["goodwe==0.3.6"]
|
||||
"requirements": ["goodwe==0.4.8"]
|
||||
}
|
||||
|
@ -54,17 +54,24 @@ async def async_setup_entry(
|
||||
# Inverter model does not support this setting
|
||||
_LOGGER.debug("Could not read inverter operation mode")
|
||||
else:
|
||||
async_add_entities(
|
||||
[
|
||||
InverterOperationModeEntity(
|
||||
device_info,
|
||||
OPERATION_MODE,
|
||||
inverter,
|
||||
[v for k, v in _MODE_TO_OPTION.items() if k in supported_modes],
|
||||
_MODE_TO_OPTION[active_mode],
|
||||
)
|
||||
]
|
||||
)
|
||||
active_mode_option = _MODE_TO_OPTION.get(active_mode)
|
||||
if active_mode_option is not None:
|
||||
async_add_entities(
|
||||
[
|
||||
InverterOperationModeEntity(
|
||||
device_info,
|
||||
OPERATION_MODE,
|
||||
inverter,
|
||||
[v for k, v in _MODE_TO_OPTION.items() if k in supported_modes],
|
||||
active_mode_option,
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Active mode %s not found in Goodwe Inverter Operation Mode Entity. Skipping entity creation",
|
||||
active_mode,
|
||||
)
|
||||
|
||||
|
||||
class InverterOperationModeEntity(SelectEntity):
|
||||
|
@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
||||
try:
|
||||
responses = await self._client.streaming_recognize(
|
||||
requests=request_generator(),
|
||||
timeout=10,
|
||||
timeout=30,
|
||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||
)
|
||||
|
||||
|
@ -218,7 +218,7 @@ class BaseGoogleCloudProvider:
|
||||
|
||||
response = await self._client.synthesize_speech(
|
||||
request,
|
||||
timeout=10,
|
||||
timeout=30,
|
||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||
)
|
||||
|
||||
|
@ -195,11 +195,15 @@ async def async_update_options(
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
# Make sure we get enabled config entries first
|
||||
entries = sorted(
|
||||
hass.config_entries.async_entries(DOMAIN),
|
||||
key=lambda e: e.disabled_by is not None,
|
||||
)
|
||||
if not any(entry.version == 1 for entry in entries):
|
||||
return
|
||||
|
||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
||||
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@ -213,9 +217,14 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
)
|
||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
||||
use_existing = True
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
||||
all_disabled = all(
|
||||
e.disabled_by is not None
|
||||
for e in entries
|
||||
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
|
||||
)
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
|
||||
|
||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
if use_existing:
|
||||
@ -228,25 +237,51 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
conversation_entity_id = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
if conversation_entity is not None:
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
|
||||
if conversation_entity_id is not None:
|
||||
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
|
||||
entity_disabled_by = conversation_entity_entry.disabled_by
|
||||
if (
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
else er.RegistryEntryDisabler.USER
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity_id,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
disabled_by=entity_disabled_by,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
if device is not None:
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
device_disabled_by = dr.DeviceEntryDisabler.USER
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
@ -266,12 +301,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_TITLE,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
minor_version=4,
|
||||
)
|
||||
|
||||
|
||||
@ -315,19 +351,58 @@ async def async_migrate_entry(
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 2:
|
||||
# Add AI Task subentry with default options
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Fix migration where the disabled_by flag was not set correctly.
|
||||
# We can currently only correct this for enabled config entries,
|
||||
# because migration does not run for disabled config entries. This
|
||||
# is asserted in tests, and if that behavior is changed, we should
|
||||
# correct also disabled config entries.
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
)
|
||||
if entry.disabled_by is None:
|
||||
# If the config entry is not disabled, we need to set the disabled_by
|
||||
# flag on devices to USER, and on entities to DEVICE, if they are set
|
||||
# to CONFIG_ENTRY.
|
||||
for device in devices:
|
||||
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
for entity in entity_entries:
|
||||
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
entity_registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
disabled_by=er.RegistryEntryDisabler.DEVICE,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _add_ai_task_subentry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> None:
|
||||
"""Add AI Task subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
@ -37,7 +37,10 @@ class GoogleGenerativeAITaskEntity(
|
||||
):
|
||||
"""Google Generative AI AI Task entity."""
|
||||
|
||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
_attr_supported_features = (
|
||||
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||
)
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user