Merge branch 'dev' of github.com:home-assistant/core into target_trigger

This commit is contained in:
abmantis 2025-07-14 19:50:38 +01:00
commit ee2c7cacfa
620 changed files with 26699 additions and 7085 deletions

View File

@ -8,6 +8,7 @@
"PYTHONASYNCIODEBUG": "1" "PYTHONASYNCIODEBUG": "1"
}, },
"features": { "features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {} "ghcr.io/devcontainers/features/github-cli:1": {}
}, },
// Port 5683 udp is used by Shelly integration // Port 5683 udp is used by Shelly integration

View File

@ -21,7 +21,7 @@ body:
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Task description label: Description
description: | description: |
Provide a clear and detailed description of the task that needs to be accomplished. 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. Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: | placeholder: |
- Roadmap opportunity: [links] - Roadmap opportunity: [link]
- Epic: [link]
- Feature request: [link] - Feature request: [link]
- Technical design documents: [link] - Technical design documents: [link]
- Prototype/mockup: [link] - Prototype/mockup: [link]
- Dependencies: [links]
validations: validations:
required: false required: false

View File

@ -1149,7 +1149,7 @@ _LOGGER.debug("Processing data: %s", data) # Use lazy logging
### Validation Commands ### Validation Commands
```bash ```bash
# Check specific integration # Check specific integration
python -m script.hassfest --integration my_integration python -m script.hassfest --integration-path homeassistant/components/my_integration
# Validate quality scale # Validate quality scale
# Check quality_scale.yaml against current rules # Check quality_scale.yaml against current rules

View File

@ -332,6 +332,9 @@ async def async_setup_hass(
if not is_virtual_env(): if not is_virtual_env():
await async_mount_local_lib_path(runtime_config.config_dir) await async_mount_local_lib_path(runtime_config.config_dir)
if hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
basic_setup_success = ( basic_setup_success = (
await async_from_config_dict(config_dict, hass) is not None 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}, {"recovery_mode": {}, "http": http_conf},
hass, hass,
) )
elif hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
if runtime_config.open_ui: if runtime_config.open_ui:
hass.add_job(open_hass_ui, hass) hass.add_job(open_hass_ui, hass)
@ -870,9 +871,9 @@ async def _async_set_up_integrations(
domains = set(integrations) & all_domains domains = set(integrations) & all_domains
_LOGGER.info( _LOGGER.info(
"Domains to be set up: %s | %s", "Domains to be set up: %s\nDependencies: %s",
domains, domains or "{}",
all_domains - domains, (all_domains - domains) or "{}",
) )
async_set_domains_to_be_loaded(hass, all_domains) 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 stage_all_domains = stage_domains | stage_dep_domains
_LOGGER.info( _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, name,
stage_domains, stage_domains,
stage_domains_unfiltered - stage_domains, (stage_domains_unfiltered - stage_domains) or "{}",
stage_dep_domains, stage_dep_domains or "{}",
stage_dep_domains_unfiltered - stage_dep_domains, (stage_dep_domains_unfiltered - stage_dep_domains) or "{}",
) )
if timeout is None: if timeout is None:

View File

@ -33,7 +33,7 @@ from .const import (
) )
from .entity import AITaskEntity from .entity import AITaskEntity
from .http import async_setup as async_setup_http 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__ = [ __all__ = [
"DOMAIN", "DOMAIN",
@ -41,7 +41,6 @@ __all__ = [
"AITaskEntityFeature", "AITaskEntityFeature",
"GenDataTask", "GenDataTask",
"GenDataTaskResult", "GenDataTaskResult",
"PlayMediaWithId",
"async_generate_data", "async_generate_data",
"async_setup", "async_setup",
"async_setup_entry", "async_setup_entry",

View File

@ -79,7 +79,9 @@ class AITaskEntity(RestoreEntity):
user_llm_prompt=DEFAULT_SYSTEM_PROMPT, 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 yield chat_log

View File

@ -10,6 +10,7 @@ generate_data:
required: true required: true
selector: selector:
text: text:
multiline: true
entity_id: entity_id:
required: false required: false
selector: selector:

View File

@ -2,30 +2,18 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, fields from dataclasses import dataclass
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components import media_source from homeassistant.components import conversation, media_source
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature 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( async def async_generate_data(
hass: HomeAssistant, hass: HomeAssistant,
*, *,
@ -52,7 +40,7 @@ async def async_generate_data(
) )
# Resolve attachments # Resolve attachments
resolved_attachments: list[PlayMediaWithId] | None = None resolved_attachments: list[conversation.Attachment] | None = None
if attachments: if attachments:
if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: 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( media = await media_source.async_resolve_media(
hass, attachment["media_content_id"], None hass, attachment["media_content_id"], None
) )
if media.path is None:
raise HomeAssistantError(
"Only local attachments are currently supported"
)
resolved_attachments.append( resolved_attachments.append(
PlayMediaWithId( conversation.Attachment(
**{
field.name: getattr(media, field.name)
for field in fields(media)
},
media_content_id=attachment["media_content_id"], 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 structure: vol.Schema | None = None
"""Optional structure for the data to be generated.""" """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.""" """List of attachments to go along the instructions."""
def __str__(self) -> str: def __str__(self) -> str:

View File

@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average"
CONF_CLIP_NEGATIVE: Final = "clip_negatives" CONF_CLIP_NEGATIVE: Final = "clip_negatives"
DOMAIN: Final = "airq" DOMAIN: Final = "airq"
MANUFACTURER: Final = "CorantGmbH" MANUFACTURER: Final = "CorantGmbH"
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
UPDATE_INTERVAL: float = 10.0 UPDATE_INTERVAL: float = 10.0

View File

@ -4,9 +4,6 @@
"health_index": { "health_index": {
"default": "mdi:heart-pulse" "default": "mdi:heart-pulse"
}, },
"absolute_humidity": {
"default": "mdi:water"
},
"oxygen": { "oxygen": {
"default": "mdi:leaf" "default": "mdi:leaf"
}, },

View File

@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator from . import AirQConfigEntry, AirQCoordinator
from .const import ( from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER
ACTIVITY_BECQUEREL_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
), ),
AirQEntityDescription( AirQEntityDescription(
key="humidity_abs", key="humidity_abs",
translation_key="absolute_humidity", device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("humidity_abs"), value=lambda data: data.get("humidity_abs"),

View File

@ -93,9 +93,6 @@
"health_index": { "health_index": {
"name": "Health index" "name": "Health index"
}, },
"absolute_humidity": {
"name": "Absolute humidity"
},
"hydrogen": { "hydrogen": {
"name": "Hydrogen" "name": "Hydrogen"
}, },

View File

@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity):
): ):
yield AlexaThermostatController(self.hass, self.entity) yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity)
if self.entity.domain == water_heater.DOMAIN and ( if (
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE 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( yield AlexaModeController(
self.entity, self.entity,
@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity):
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
) )
force_range_controller = False 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( yield AlexaModeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
) )
@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity):
yield AlexaPowerController(self.entity) yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] 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( yield AlexaModeController(
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
) )
@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity):
"""Yield the supported interfaces.""" """Yield the supported interfaces."""
yield AlexaPowerController(self.entity) yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 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( yield AlexaModeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
) )

View File

@ -6,7 +6,12 @@ from collections.abc import Mapping
from typing import Any from typing import Any
from aioamazondevices.api import AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
WrongCountry,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -57,6 +62,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except CannotAuthenticate:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
except WrongCountry: except WrongCountry:
errors["base"] = "wrong_country" errors["base"] = "wrong_country"
else: else:
@ -106,6 +113,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except CannotAuthenticate:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
else: else:
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
reauth_entry, reauth_entry,

View File

@ -52,8 +52,18 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
try: try:
await self.api.login_mode_stored_data() await self.api.login_mode_stored_data()
return await self.api.get_devices_data() return await self.api.get_devices_data()
except (CannotConnect, CannotRetrieveData) as err: except CannotConnect as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from 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: except CannotAuthenticate as err:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,

View File

@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "bronze", "quality_scale": "silver",
"requirements": ["aioamazondevices==3.2.3"] "requirements": ["aioamazondevices==3.2.10"]
} }

View File

@ -28,33 +28,31 @@ rules:
# Silver # Silver
action-exceptions: done action-exceptions: done
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: todo docs-configuration-parameters: done
docs-installation-parameters: todo docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: done reauthentication-flow: done
test-coverage: test-coverage: done
status: todo
comment: all tests missing
# Gold # Gold
devices: done devices: done
diagnostics: todo diagnostics: done
discovery-update-info: discovery-update-info:
status: exempt status: exempt
comment: Network information not relevant comment: Network information not relevant
discovery: discovery:
status: exempt status: exempt
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration 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-data-update: done
docs-examples: todo docs-examples: done
docs-known-limitations: todo docs-known-limitations: todo
docs-supported-devices: todo docs-supported-devices: done
docs-supported-functions: todo docs-supported-functions: done
docs-troubleshooting: todo docs-troubleshooting: todo
docs-use-cases: todo docs-use-cases: done
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done

View File

@ -43,6 +43,7 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
@ -84,10 +85,10 @@
} }
}, },
"exceptions": { "exceptions": {
"cannot_connect": { "cannot_connect_with_error": {
"message": "Error connecting: {error}" "message": "Error connecting: {error}"
}, },
"cannot_retrieve_data": { "cannot_retrieve_data_with_error": {
"message": "Error retrieving data: {error}" "message": "Error retrieving data: {error}"
} }
} }

View File

@ -26,14 +26,14 @@ def alexa_api_call[_T: AmazonEntity, **_P](
self.coordinator.last_update_success = False self.coordinator.last_update_success = False
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="cannot_connect", translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},
) from err ) from err
except CannotRetrieveData as err: except CannotRetrieveData as err:
self.coordinator.last_update_success = False self.coordinator.last_update_success = False
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="cannot_retrieve_data", translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},
) from err ) from err

View File

@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model" 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" CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 1024 RECOMMENDED_MAX_TOKENS = 3000
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TEMPERATURE = 1.0
CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_BUDGET = "thinking_budget"

View File

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv", "documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyatv", "srptools"], "loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.16.0"], "requirements": ["pyatv==0.16.1"],
"zeroconf": [ "zeroconf": [
"_mediaremotetv._tcp.local.", "_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.", "_companion-link._tcp.local.",

View File

@ -38,8 +38,6 @@ from .pipeline import (
async_create_default_pipeline, async_create_default_pipeline,
async_get_pipeline, async_get_pipeline,
async_get_pipelines, async_get_pipelines,
async_migrate_engine,
async_run_migrations,
async_setup_pipeline_store, async_setup_pipeline_store,
async_update_pipeline, async_update_pipeline,
) )
@ -61,7 +59,6 @@ __all__ = (
"WakeWordSettings", "WakeWordSettings",
"async_create_default_pipeline", "async_create_default_pipeline",
"async_get_pipelines", "async_get_pipelines",
"async_migrate_engine",
"async_pipeline_from_audio_stream", "async_pipeline_from_audio_stream",
"async_setup", "async_setup",
"async_update_pipeline", "async_update_pipeline",
@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_LAST_WAKE_UP] = {} hass.data[DATA_LAST_WAKE_UP] = {}
await async_setup_pipeline_store(hass) await async_setup_pipeline_store(hass)
await async_run_migrations(hass)
async_register_websocket_api(hass) async_register_websocket_api(hass)
return True return True

View File

@ -3,7 +3,6 @@
DOMAIN = "assist_pipeline" DOMAIN = "assist_pipeline"
DATA_CONFIG = f"{DOMAIN}.config" DATA_CONFIG = f"{DOMAIN}.config"
DATA_MIGRATIONS = f"{DOMAIN}_migrations"
DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds

View File

@ -13,7 +13,7 @@ from pathlib import Path
from queue import Empty, Queue from queue import Empty, Queue
from threading import Thread from threading import Thread
import time import time
from typing import TYPE_CHECKING, Any, Literal, cast from typing import TYPE_CHECKING, Any, cast
import wave import wave
import hass_nabucasa import hass_nabucasa
@ -49,7 +49,6 @@ from .const import (
CONF_DEBUG_RECORDING_DIR, CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG, DATA_CONFIG,
DATA_LAST_WAKE_UP, DATA_LAST_WAKE_UP,
DATA_MIGRATIONS,
DOMAIN, DOMAIN,
MS_PER_CHUNK, MS_PER_CHUNK,
SAMPLE_CHANNELS, SAMPLE_CHANNELS,
@ -2059,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
return PipelineData(pipeline_store) 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 @dataclass
class PipelineConversationData: class PipelineConversationData:
"""Hold data for the duration of a conversation.""" """Hold data for the duration of a conversation."""

View File

@ -6,6 +6,7 @@ from datetime import timedelta
import logging import logging
API_CO2 = "carbon_dioxide" API_CO2 = "carbon_dioxide"
API_DEW_POINT = "dew_point"
API_DUST = "dust" API_DUST = "dust"
API_HUMID = "humidity" API_HUMID = "humidity"
API_LUX = "illuminance" API_LUX = "illuminance"

View File

@ -34,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
API_CO2, API_CO2,
API_DEW_POINT,
API_DUST, API_DUST,
API_HUMID, API_HUMID,
API_LUX, API_LUX,
@ -110,6 +111,15 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = (
unique_id_tag="CO2", # matches legacy format unique_id_tag="CO2", # matches legacy format
state_class=SensorStateClass.MEASUREMENT, 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, ...] = ( SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (

View File

@ -57,6 +57,9 @@
}, },
"sound_level": { "sound_level": {
"name": "Sound level" "name": "Sound level"
},
"dew_point": {
"name": "Dew point"
} }
} }
} }

View File

@ -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) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hub.setup() 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(hub.teardown)
config_entry.async_on_unload( config_entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown)

View File

@ -15,12 +15,12 @@
], ],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==0.22.3", "bleak==1.0.1",
"bleak-retry-connector==3.9.0", "bleak-retry-connector==4.0.0",
"bluetooth-adapters==0.21.4", "bluetooth-adapters==2.0.0",
"bluetooth-auto-recovery==1.5.2", "bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2", "bluetooth-data-tools==1.28.2",
"dbus-fast==2.43.0", "dbus-fast==2.43.0",
"habluetooth==3.49.0" "habluetooth==4.0.1"
] ]
} }

View File

@ -8,20 +8,33 @@ from bring_api import Bring
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import ( from .coordinator import (
BringActivityCoordinator, BringActivityCoordinator,
BringConfigEntry, BringConfigEntry,
BringCoordinators, BringCoordinators,
BringDataUpdateCoordinator, 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] PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
_LOGGER = logging.getLogger(__name__) _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: async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""Set up Bring! from a config entry.""" """Set up Bring! from a config entry."""

View File

@ -7,5 +7,8 @@ DOMAIN = "bring"
ATTR_SENDER: Final = "sender" ATTR_SENDER: Final = "sender"
ATTR_ITEM_NAME: Final = "item" ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message" ATTR_NOTIFICATION_TYPE: Final = "message"
ATTR_REACTION: Final = "reaction"
ATTR_ACTIVITY: Final = "uuid"
ATTR_RECEIVER: Final = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message" SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"

View File

@ -35,6 +35,9 @@
"services": { "services": {
"send_message": { "send_message": {
"service": "mdi:cellphone-message" "service": "mdi:cellphone-message"
},
"send_reaction": {
"service": "mdi:thumb-up"
} }
} }
} }

View 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,
)

View File

@ -21,3 +21,28 @@ send_message:
required: false required: false
selector: selector:
text: 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

View File

@ -144,6 +144,19 @@
}, },
"notify_request_failed": { "notify_request_failed": {
"message": "Failed to send push notification for Bring! due to a connection error, try again later" "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": { "services": {
@ -164,6 +177,20 @@
"description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" "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": { "selector": {

View File

@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = {
Platform.SELECT: {"HYS"}, Platform.SELECT: {"HYS"},
Platform.SENSOR: { Platform.SENSOR: {
"A1", "A1",
"A2",
"MP1S", "MP1S",
"RM4MINI", "RM4MINI",
"RM4PRO", "RM4PRO",

View File

@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE, PERCENTAGE,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key="air_quality", key="air_quality",
device_class=SensorDeviceClass.AQI, 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( SensorEntityDescription(
key="humidity", key="humidity",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,

View File

@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager
"""Return an update manager for a given Broadlink device.""" """Return an update manager for a given Broadlink device."""
update_managers: dict[str, type[BroadlinkUpdateManager]] = { update_managers: dict[str, type[BroadlinkUpdateManager]] = {
"A1": BroadlinkA1UpdateManager, "A1": BroadlinkA1UpdateManager,
"A2": BroadlinkA2UpdateManager,
"BG1": BroadlinkBG1UpdateManager, "BG1": BroadlinkBG1UpdateManager,
"HYS": BroadlinkThermostatUpdateManager, "HYS": BroadlinkThermostatUpdateManager,
"LB1": BroadlinkLB1UpdateManager, "LB1": BroadlinkLB1UpdateManager,
@ -118,6 +119,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]):
return await self.device.async_request(self.device.api.check_sensors_raw) 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]): class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]):
"""Manages updates for Broadlink MP1 devices.""" """Manages updates for Broadlink MP1 devices."""

View File

@ -8,7 +8,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"requirements": ["brother==4.3.1"], "requirements": ["brother==5.0.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_printer._tcp.local.", "type": "_printer._tcp.local.",

View File

@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.105.0"], "requirements": ["hass-nabucasa==0.106.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any
from hass_nabucasa.payments_api import SubscriptionInfo
import voluptuous as vol import voluptuous as vol
from homeassistant.components.repairs import ( from homeassistant.components.repairs import (
@ -26,7 +26,7 @@ MAX_RETRIES = 60 # This allows for 10 minutes of retries
@callback @callback
def async_manage_legacy_subscription_issue( def async_manage_legacy_subscription_issue(
hass: HomeAssistant, hass: HomeAssistant,
subscription_info: dict[str, Any], subscription_info: SubscriptionInfo,
) -> None: ) -> None:
"""Manage the legacy subscription issue. """Manage the legacy subscription issue.
@ -50,7 +50,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow.""" """Handler for an issue fixing flow."""
wait_task: asyncio.Task | None = None 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: async def async_step_init(self, _: None = None) -> FlowResult:
"""Handle the first step of a fix flow.""" """Handle the first step of a fix flow."""

View File

@ -8,6 +8,7 @@ from typing import Any
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from hass_nabucasa import Cloud, cloud_api from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo
from .client import CloudClient from .client import CloudClient
from .const import REQUEST_TIMEOUT from .const import REQUEST_TIMEOUT
@ -15,21 +16,13 @@ from .const import REQUEST_TIMEOUT
_LOGGER = logging.getLogger(__name__) _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.""" """Fetch the subscription info."""
try: try:
async with asyncio.timeout(REQUEST_TIMEOUT): async with asyncio.timeout(REQUEST_TIMEOUT):
return await cloud_api.async_subscription_info(cloud) return await cloud.payments.subscription_info()
except TimeoutError: except PaymentsApiError as exception:
_LOGGER.error( _LOGGER.error("Failed to fetch subscription information - %s", exception)
(
"A timeout of %s was reached while trying to fetch subscription"
" information"
),
REQUEST_TIMEOUT,
)
except ClientError:
_LOGGER.error("Failed to fetch subscription information")
return None return None

View File

@ -34,6 +34,7 @@ from .agent_manager import (
from .chat_log import ( from .chat_log import (
AssistantContent, AssistantContent,
AssistantContentDeltaDict, AssistantContentDeltaDict,
Attachment,
ChatLog, ChatLog,
Content, Content,
ConverseError, ConverseError,
@ -51,7 +52,6 @@ from .const import (
DATA_DEFAULT_ENTITY, DATA_DEFAULT_ENTITY,
DOMAIN, DOMAIN,
HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT,
OLD_HOME_ASSISTANT_AGENT,
SERVICE_PROCESS, SERVICE_PROCESS,
SERVICE_RELOAD, SERVICE_RELOAD,
ConversationEntityFeature, ConversationEntityFeature,
@ -65,9 +65,9 @@ from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [ __all__ = [
"DOMAIN", "DOMAIN",
"HOME_ASSISTANT_AGENT", "HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT",
"AssistantContent", "AssistantContent",
"AssistantContentDeltaDict", "AssistantContentDeltaDict",
"Attachment",
"ChatLog", "ChatLog",
"Content", "Content",
"ConversationEntity", "ConversationEntity",
@ -270,15 +270,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) 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: async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands.""" """Parse text into commands."""
text = service.data[ATTR_TEXT] text = service.data[ATTR_TEXT]

View File

@ -12,12 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers import config_validation as cv, intent, singleton
from .const import ( from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
DATA_COMPONENT,
DATA_DEFAULT_ENTITY,
HOME_ASSISTANT_AGENT,
OLD_HOME_ASSISTANT_AGENT,
)
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ( from .models import (
AbstractConversationAgent, AbstractConversationAgent,
@ -54,7 +49,7 @@ def async_get_agent(
hass: HomeAssistant, agent_id: str | None = None hass: HomeAssistant, agent_id: str | None = None
) -> AbstractConversationAgent | ConversationEntity | None: ) -> AbstractConversationAgent | ConversationEntity | None:
"""Get specified agent.""" """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] return hass.data[DATA_DEFAULT_ENTITY]
if "." in agent_id: if "." in agent_id:

View File

@ -8,6 +8,7 @@ from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import asdict, dataclass, field, replace from dataclasses import asdict, dataclass, field, replace
import logging import logging
from pathlib import Path
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
import voluptuous as vol import voluptuous as vol
@ -136,6 +137,24 @@ class UserContent:
role: Literal["user"] = field(init=False, default="user") role: Literal["user"] = field(init=False, default="user")
content: str 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) @dataclass(frozen=True)

View File

@ -16,7 +16,6 @@ if TYPE_CHECKING:
DOMAIN = "conversation" DOMAIN = "conversation"
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
HOME_ASSISTANT_AGENT = "conversation.home_assistant" HOME_ASSISTANT_AGENT = "conversation.home_assistant"
OLD_HOME_ASSISTANT_AGENT = "homeassistant"
ATTR_TEXT = "text" ATTR_TEXT = "text"
ATTR_LANGUAGE = "language" ATTR_LANGUAGE = "language"

View File

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SOURCE, Platform from homeassistant.const import CONF_SOURCE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -9,12 +11,18 @@ from homeassistant.helpers.device import (
async_entity_id_to_device_id, async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device, 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Derivative from a config entry.""" """Set up Derivative from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device( async_remove_stale_devices_links_keep_entity_device(
hass, entry.entry_id, entry.options[CONF_SOURCE] 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}, 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( entry.async_on_unload(
async_handle_source_entity_changes( async_handle_source_entity_changes(
hass, hass,
add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id, helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id( source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_SOURCE] hass, entry.options[CONF_SOURCE]
), ),
source_entity_id_or_uuid=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,)) 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) 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

View File

@ -141,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
VERSION = 1
MINOR_VERSION = 3
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""
return cast(str, options[CONF_NAME]) return cast(str, options[CONF_NAME])

View File

@ -34,8 +34,7 @@ from homeassistant.core import (
callback, callback,
) )
from homeassistant.helpers import config_validation as cv, entity_registry as er 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 import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback, AddConfigEntryEntitiesCallback,
AddEntitiesCallback, AddEntitiesCallback,
@ -118,30 +117,21 @@ async def async_setup_entry(
registry, config_entry.options[CONF_SOURCE] 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): if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
max_sub_interval = cv.time_period(max_sub_interval_dict) max_sub_interval = cv.time_period(max_sub_interval_dict)
else: else:
max_sub_interval = None max_sub_interval = None
derivative_sensor = DerivativeSensor( derivative_sensor = DerivativeSensor(
hass,
name=config_entry.title, name=config_entry.title,
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
source_entity=source_entity_id, source_entity=source_entity_id,
time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]),
unique_id=config_entry.entry_id, unique_id=config_entry.entry_id,
unit_of_measurement=None, 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], unit_time=config_entry.options[CONF_UNIT_TIME],
device_info=device_info,
max_sub_interval=max_sub_interval, max_sub_interval=max_sub_interval,
) )
@ -156,6 +146,7 @@ async def async_setup_platform(
) -> None: ) -> None:
"""Set up the derivative sensor.""" """Set up the derivative sensor."""
derivative = DerivativeSensor( derivative = DerivativeSensor(
hass,
name=config.get(CONF_NAME), name=config.get(CONF_NAME),
round_digits=config[CONF_ROUND_DIGITS], round_digits=config[CONF_ROUND_DIGITS],
source_entity=config[CONF_SOURCE], source_entity=config[CONF_SOURCE],
@ -178,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
def __init__( def __init__(
self, self,
hass: HomeAssistant,
*, *,
name: str | None, name: str | None,
round_digits: int, round_digits: int,
@ -188,11 +180,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
unit_time: UnitOfTime, unit_time: UnitOfTime,
max_sub_interval: timedelta | None, max_sub_interval: timedelta | None,
unique_id: str | None, unique_id: str | None,
device_info: DeviceInfo | None = None,
) -> None: ) -> None:
"""Initialize the derivative sensor.""" """Initialize the derivative sensor."""
self._attr_unique_id = unique_id 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._sensor_source_id = source_entity
self._round_digits = round_digits self._round_digits = round_digits
self._attr_native_value = round(Decimal(0), round_digits) self._attr_native_value = round(Decimal(0), round_digits)

View File

@ -19,6 +19,7 @@ from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
device_registry as dr, device_registry as dr,
integration_platform, integration_platform,
issue_registry as ir,
) )
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.json import ( from homeassistant.helpers.json import (
@ -187,6 +188,7 @@ def async_format_manifest(manifest: Manifest) -> Manifest:
async def _async_get_json_file_response( async def _async_get_json_file_response(
hass: HomeAssistant, hass: HomeAssistant,
data: Mapping[str, Any], data: Mapping[str, Any],
data_issues: list[dict[str, Any]] | None,
filename: str, filename: str,
domain: str, domain: str,
d_id: str, d_id: str,
@ -213,6 +215,8 @@ async def _async_get_json_file_response(
"setup_times": async_get_domain_setup_times(hass, domain), "setup_times": async_get_domain_setup_times(hass, domain),
"data": data, "data": data,
} }
if data_issues is not None:
payload["issues"] = data_issues
try: try:
json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder) json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder)
except TypeError: except TypeError:
@ -275,6 +279,14 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
filename = f"{config_entry.domain}-{config_entry.entry_id}" 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: if not device_diagnostics:
# Config entry diagnostics # Config entry diagnostics
if info.config_entry_diagnostics is None: if info.config_entry_diagnostics is None:
@ -282,7 +294,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
data = await info.config_entry_diagnostics(hass, config_entry) data = await info.config_entry_diagnostics(hass, config_entry)
filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}" filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}"
return await _async_get_json_file_response( 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 # Device diagnostics
@ -300,5 +312,5 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
data = await info.device_diagnostics(hass, config_entry, device) data = await info.device_diagnostics(hass, config_entry, device)
return await _async_get_json_file_response( 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
) )

View File

@ -106,6 +106,7 @@ ECOWITT_SENSORS_MAPPING: Final = {
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
), ),
EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( EcoWittSensorTypes.CO2_PPM: SensorEntityDescription(
key="CO2_PPM", key="CO2_PPM",
@ -191,12 +192,14 @@ ECOWITT_SENSORS_MAPPING: Final = {
device_class=SensorDeviceClass.WIND_SPEED, device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
), ),
EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription( EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription(
key="SPEED_MPH", key="SPEED_MPH",
device_class=SensorDeviceClass.WIND_SPEED, device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
), ),
EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription( EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription(
key="PRESSURE_HPA", key="PRESSURE_HPA",

View File

@ -179,6 +179,47 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, 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): class EmoncmsOptionsFlow(OptionsFlow):
"""Emoncms Options flow handler.""" """Emoncms Options flow handler."""

View File

@ -22,7 +22,9 @@
} }
}, },
"abort": { "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": { "selector": {

View File

@ -41,13 +41,8 @@ SUPPORTED_STATE_CLASSES = {
SensorStateClass.TOTAL, SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING, SensorStateClass.TOTAL_INCREASING,
} }
VALID_ENERGY_UNITS: set[str] = { VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy)
UnitOfEnergy.GIGA_JOULE,
UnitOfEnergy.KILO_WATT_HOUR,
UnitOfEnergy.MEGA_JOULE,
UnitOfEnergy.MEGA_WATT_HOUR,
UnitOfEnergy.WATT_HOUR,
}
VALID_ENERGY_UNITS_GAS = { VALID_ENERGY_UNITS_GAS = {
UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET,
UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET,

View File

@ -21,14 +21,9 @@ from .const import DOMAIN
ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: ( sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
UnitOfEnergy.GIGA_JOULE,
UnitOfEnergy.KILO_WATT_HOUR,
UnitOfEnergy.MEGA_JOULE,
UnitOfEnergy.MEGA_WATT_HOUR,
UnitOfEnergy.WATT_HOUR,
)
} }
ENERGY_PRICE_UNITS = tuple( ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
) )
@ -39,13 +34,9 @@ GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.GAS, sensor.SensorDeviceClass.GAS,
) )
GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
sensor.SensorDeviceClass.ENERGY: ( sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[
UnitOfEnergy.GIGA_JOULE, sensor.SensorDeviceClass.ENERGY
UnitOfEnergy.KILO_WATT_HOUR, ],
UnitOfEnergy.MEGA_JOULE,
UnitOfEnergy.MEGA_WATT_HOUR,
UnitOfEnergy.WATT_HOUR,
),
sensor.SensorDeviceClass.GAS: ( sensor.SensorDeviceClass.GAS: (
UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET,
UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET,

View File

@ -22,5 +22,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"]
} }

View File

@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel(
async def async_alarm_disarm(self, code: str | None = None) -> None: async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command.""" """Send disarm command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_arm_home(self, code: str | None = None) -> None: async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command.""" """Send arm home command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_arm_night(self, code: str | None = None) -> None: async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_arm_vacation(self, code: str | None = None) -> None: async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
self._client.alarm_control_panel_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 @convert_api_error_ha_error
async def async_alarm_trigger(self, code: str | None = None) -> None: async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command.""" """Send alarm trigger command."""
self._client.alarm_control_panel_command( self._client.alarm_control_panel_command(
self._key, AlarmControlPanelCommand.TRIGGER, code self._key,
AlarmControlPanelCommand.TRIGGER,
code,
device_id=self._static_info.device_id,
) )

View File

@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """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( async_setup_entry = partial(

View File

@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
if ATTR_TARGET_TEMP_HIGH in kwargs: if ATTR_TARGET_TEMP_HIGH in kwargs:
data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] 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 @convert_api_error_ha_error
async def async_set_humidity(self, humidity: int) -> None: async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity.""" """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 @convert_api_error_ha_error
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode.""" """Set new target operation mode."""
self._client.climate_command( 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 @convert_api_error_ha_error
@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
kwargs["custom_preset"] = preset_mode kwargs["custom_preset"] = preset_mode
else: else:
kwargs["preset"] = _PRESETS.from_hass(preset_mode) 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 @convert_api_error_ha_error
async def async_set_fan_mode(self, fan_mode: str) -> None: 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 kwargs["custom_fan_mode"] = fan_mode
else: else:
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) 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 @convert_api_error_ha_error
async def async_set_swing_mode(self, swing_mode: str) -> None: async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode.""" """Set new swing mode."""
self._client.climate_command( 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,
) )

View File

@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover.""" """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 @convert_api_error_ha_error
async def async_close_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover.""" """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 @convert_api_error_ha_error
async def async_stop_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover.""" """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 @convert_api_error_ha_error
async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position.""" """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 @convert_api_error_ha_error
async def async_open_cover_tilt(self, **kwargs: Any) -> None: async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt.""" """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 @convert_api_error_ha_error
async def async_close_cover_tilt(self, **kwargs: Any) -> None: async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt.""" """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 @convert_api_error_ha_error
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position.""" """Move the cover tilt to a specific position."""
tilt_position: int = kwargs[ATTR_TILT_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( async_setup_entry = partial(

View File

@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity):
async def async_set_value(self, value: date) -> None: async def async_set_value(self, value: date) -> None:
"""Update the current date.""" """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( async_setup_entry = partial(

View File

@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity
async def async_set_value(self, value: datetime) -> None: async def async_set_value(self, value: datetime) -> None:
"""Update the current datetime.""" """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( async_setup_entry = partial(

View File

@ -33,7 +33,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
# Import config flow so that it's added to the registry # 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 from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -59,17 +64,32 @@ def async_static_info_updated(
device_info = entry_data.device_info device_info = entry_data.device_info
if TYPE_CHECKING: if TYPE_CHECKING:
assert device_info is not None assert device_info is not None
new_infos: dict[int, EntityInfo] = {} new_infos: dict[DeviceEntityKey, EntityInfo] = {}
add_entities: list[_EntityT] = [] add_entities: list[_EntityT] = []
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
dev_reg = dr.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: 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 # 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) entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity) add_entities.append(entity)
continue continue
@ -78,7 +98,7 @@ def async_static_info_updated(
if old_info.device_id == info.device_id: if old_info.device_id == info.device_id:
continue 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) 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) 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: if old_unique_id != new_unique_id:
updates["new_unique_id"] = new_unique_id updates["new_unique_id"] = new_unique_id
# Update device assignment # Update device assignment in registry
if info.device_id: if info.device_id:
# Entity now belongs to a sub device # Entity now belongs to a sub device
new_device = dev_reg.async_get_device( new_device = dev_reg.async_get_device(
@ -118,10 +138,32 @@ def async_static_info_updated(
if new_device: if new_device:
updates["device_id"] = new_device.id updates["device_id"] = new_device.id
# Apply all updates at once # Apply all registry updates at once
if updates: if updates:
ent_reg.async_update_entity(entity_id, **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 # Anything still in current_infos is now gone
if current_infos: if current_infos:
entry_data.async_remove_entities( entry_data.async_remove_entities(
@ -341,7 +383,10 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
) )
self.async_on_remove( self.async_on_remove(
entry_data.async_subscribe_state_update( 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( self.async_on_remove(
@ -349,8 +394,29 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
self._static_info, self._on_static_info_update 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() 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 @callback
def _on_static_info_update(self, static_info: EntityInfo) -> None: def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Save the static info for this entity when it changes. """Save the static info for this entity when it changes.

View File

@ -60,7 +60,9 @@ from .const import DOMAIN
from .dashboard import async_get_dashboard from .dashboard import async_get_dashboard
type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData] 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()} 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 # When the disconnect callback is called, we mark all states
# as stale so we will always dispatch a state update when the # as stale so we will always dispatch a state update when the
# device reconnects. This is the same format as state_subscriptions. # device reconnects. This is the same format as state_subscriptions.
stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) stale_state: set[EntityStateKey] = field(default_factory=set)
info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) info: dict[type[EntityInfo], dict[DeviceEntityKey, EntityInfo]] = field(
default_factory=dict
)
services: dict[int, UserService] = field(default_factory=dict) services: dict[int, UserService] = field(default_factory=dict)
available: bool = False available: bool = False
expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) 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) api_version: APIVersion = field(default_factory=APIVersion)
cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
disconnect_callbacks: set[CALLBACK_TYPE] = field(default_factory=set) 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 default_factory=dict
) )
device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set) device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set)
@ -164,7 +168,7 @@ class RuntimeEntryData:
type[EntityInfo], list[Callable[[list[EntityInfo]], None]] type[EntityInfo], list[Callable[[list[EntityInfo]], None]]
] = field(default_factory=dict) ] = field(default_factory=dict)
entity_info_key_updated_callbacks: dict[ entity_info_key_updated_callbacks: dict[
tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] EntityInfoKey, list[Callable[[EntityInfo], None]]
] = field(default_factory=dict) ] = field(default_factory=dict)
original_options: dict[str, Any] = field(default_factory=dict) original_options: dict[str, Any] = field(default_factory=dict)
media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
@ -177,6 +181,9 @@ class RuntimeEntryData:
default_factory=list default_factory=list
) )
device_id_to_name: dict[int, str] = field(default_factory=dict) device_id_to_name: dict[int, str] = field(default_factory=dict)
entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field(
default_factory=dict
)
@property @property
def name(self) -> str: def name(self) -> str:
@ -210,7 +217,7 @@ class RuntimeEntryData:
callback_: Callable[[EntityInfo], None], callback_: Callable[[EntityInfo], None],
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register to receive callbacks when static info is updated for a specific key.""" """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 = self.entity_info_key_updated_callbacks.setdefault(callback_key, [])
callbacks.append(callback_) callbacks.append(callback_)
return partial(callbacks.remove, callback_) return partial(callbacks.remove, callback_)
@ -250,7 +257,9 @@ class RuntimeEntryData:
"""Call static info updated callbacks.""" """Call static info updated callbacks."""
callbacks = self.entity_info_key_updated_callbacks callbacks = self.entity_info_key_updated_callbacks
for static_info in static_infos: 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) callback_(static_info)
async def _ensure_platforms_loaded( async def _ensure_platforms_loaded(
@ -342,12 +351,13 @@ class RuntimeEntryData:
@callback @callback
def async_subscribe_state_update( def async_subscribe_state_update(
self, self,
device_id: int,
state_type: type[EntityState], state_type: type[EntityState],
state_key: int, state_key: int,
entity_callback: CALLBACK_TYPE, entity_callback: CALLBACK_TYPE,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Subscribe to state updates.""" """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 self.state_subscriptions[subscription_key] = entity_callback
return partial(delitem, self.state_subscriptions, subscription_key) return partial(delitem, self.state_subscriptions, subscription_key)
@ -359,7 +369,7 @@ class RuntimeEntryData:
stale_state = self.stale_state stale_state = self.stale_state
current_state_by_type = self.state[state_type] current_state_by_type = self.state[state_type]
current_state = current_state_by_type.get(key, _SENTINEL) current_state = current_state_by_type.get(key, _SENTINEL)
subscription_key = (state_type, key) subscription_key = (state_type, state.device_id, key)
if ( if (
current_state == state current_state == state
and subscription_key not in stale_state and subscription_key not in stale_state
@ -367,7 +377,7 @@ class RuntimeEntryData:
and not ( and not (
state_type is SensorState state_type is SensorState
and (platform_info := self.info.get(SensorInfo)) 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 and (cast(SensorInfo, entity_info)).force_update
) )
): ):
@ -520,3 +530,26 @@ class RuntimeEntryData:
"""Notify listeners that the Assist satellite wake word has been set.""" """Notify listeners that the Assist satellite wake word has been set."""
for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
callback_(wake_word_id) 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_()

View File

@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
ORDERED_NAMED_FAN_SPEEDS, percentage ORDERED_NAMED_FAN_SPEEDS, percentage
) )
data["speed"] = named_speed 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( async def async_turn_on(
self, self,
@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan.""" """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 @convert_api_error_ha_error
async def async_oscillate(self, oscillating: bool) -> None: async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan.""" """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 @convert_api_error_ha_error
async def async_set_direction(self, direction: str) -> None: async def async_set_direction(self, direction: str) -> None:
"""Set direction of the fan.""" """Set direction of the fan."""
self._client.fan_command( 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 @convert_api_error_ha_error
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan.""" """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 @property
@esphome_state_property @esphome_state_property

View File

@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
# (fewest capabilities set) # (fewest capabilities set)
data["color_mode"] = _least_complex_color_mode(color_modes) 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 @convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None: 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]] data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
if ATTR_TRANSITION in kwargs: if ATTR_TRANSITION in kwargs:
data["transition_length"] = kwargs[ATTR_TRANSITION] data["transition_length"] = kwargs[ATTR_TRANSITION]
self._client.light_command(**data) self._client.light_command(**data, device_id=self._static_info.device_id)
@property @property
@esphome_state_property @esphome_state_property

View File

@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock.""" """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 @convert_api_error_ha_error
async def async_unlock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock.""" """Unlock the lock."""
code = kwargs.get(ATTR_CODE) 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 @convert_api_error_ha_error
async def async_open(self, **kwargs: Any) -> None: async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch.""" """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( async_setup_entry = partial(

View File

@ -588,7 +588,7 @@ class ESPHomeManager:
# Mark state as stale so that we will always dispatch # Mark state as stale so that we will always dispatch
# the next state update of that type when the device reconnects # the next state update of that type when the device reconnects
entry_data.stale_state = { 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 state_dict in entry_data.state.values()
for key, entity_state in state_dict.items() for key, entity_state in state_dict.items()
} }

View File

@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==34.1.0", "aioesphomeapi==35.0.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==2.16.0" "bleak-esphome==3.1.0"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]
} }

View File

@ -132,7 +132,10 @@ class EsphomeMediaPlayer(
media_id = proxy_url media_id = proxy_url
self._client.media_player_command( 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: async def async_will_remove_from_hass(self) -> None:
@ -214,22 +217,36 @@ class EsphomeMediaPlayer(
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1.""" """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 @convert_api_error_ha_error
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send pause command.""" """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 @convert_api_error_ha_error
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send play command.""" """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 @convert_api_error_ha_error
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """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 @convert_api_error_ha_error
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
@ -237,6 +254,7 @@ class EsphomeMediaPlayer(
self._client.media_player_command( self._client.media_player_command(
self._key, self._key,
command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE,
device_id=self._static_info.device_id,
) )

View File

@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Update the current value.""" """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( async_setup_entry = partial(

View File

@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """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): class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect):

View File

@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """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 @convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """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( async_setup_entry = partial(

View File

@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_set_value(self, value: str) -> None: async def async_set_value(self, value: str) -> None:
"""Update the current value.""" """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( async_setup_entry = partial(

View File

@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity):
async def async_set_value(self, value: time) -> None: async def async_set_value(self, value: time) -> None:
"""Update the current time.""" """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( async_setup_entry = partial(

View File

@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Command device to check for update.""" """Command device to check for update."""
if self.available: 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 @convert_api_error_ha_error
async def async_install( async def async_install(
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
) -> None: ) -> None:
"""Command device to install update.""" """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,
)

View File

@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity):
@convert_api_error_ha_error @convert_api_error_ha_error
async def async_open_valve(self, **kwargs: Any) -> None: async def async_open_valve(self, **kwargs: Any) -> None:
"""Open the valve.""" """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 @convert_api_error_ha_error
async def async_close_valve(self, **kwargs: Any) -> None: async def async_close_valve(self, **kwargs: Any) -> None:
"""Close valve.""" """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 @convert_api_error_ha_error
async def async_stop_valve(self, **kwargs: Any) -> None: async def async_stop_valve(self, **kwargs: Any) -> None:
"""Stop the valve.""" """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 @convert_api_error_ha_error
async def async_set_valve_position(self, position: float) -> None: async def async_set_valve_position(self, position: float) -> None:
"""Move the valve to a specific position.""" """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( async_setup_entry = partial(

View File

@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
for device in new_data.devices.values(): for device in new_data.devices.values():
# create device registry entry for new main devices # create device registry entry for new main devices
if ( if device.ain not in self.data.devices and (
device.ain not in self.data.devices device.device_and_unit_id[1] is None
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( dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id, config_entry_id=self.config_entry.entry_id,
name=device.name, name=device.name,
identifiers={(DOMAIN, device.ain)}, identifiers={(DOMAIN, device.device_and_unit_id[0])},
manufacturer=device.manufacturer, manufacturer=device.manufacturer,
model=device.productname, model=device.productname,
sw_version=device.fw_version, sw_version=device.fw_version,

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250702.1"] "requirements": ["home-assistant-frontend==20250702.2"]
} }

View File

@ -13,6 +13,7 @@ from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -74,6 +75,7 @@ async def async_setup_entry(
device = DeviceInfo( device = DeviceInfo(
identifiers={(DOMAIN, address)}, identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=name, name=name,
sw_version=sw_version, sw_version=sw_version,
manufacturer=manufacturer, manufacturer=manufacturer,

View File

@ -1,5 +1,7 @@
"""The generic_hygrostat component.""" """The generic_hygrostat component."""
import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.components.humidifier import HumidifierDeviceClass
@ -16,7 +18,10 @@ from homeassistant.helpers.device import (
async_remove_stale_devices_links_keep_entity_device, async_remove_stale_devices_links_keep_entity_device,
) )
from homeassistant.helpers.event import async_track_entity_registry_updated_event 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 from homeassistant.helpers.typing import ConfigType
DOMAIN = "generic_hygrostat" DOMAIN = "generic_hygrostat"
@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Generic Hygrostat component.""" """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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry.""" """Set up from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device( async_remove_stale_devices_links_keep_entity_device(
hass, hass,
entry.entry_id, 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}, 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( entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidifer, # 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 # but not the humidity sensor because the generic_hygrostat adds itself to the
# humidifier's device. # humidifier's device.
async_handle_source_entity_changes( async_handle_source_entity_changes(
hass, hass,
add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id, helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id( source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_HUMIDIFIER] hass, entry.options[CONF_HUMIDIFIER]
), ),
source_entity_id_or_uuid=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 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: async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed.""" """Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@ -92,6 +92,8 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow.""" """Handle a config or options flow."""
MINOR_VERSION = 2
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW

View File

@ -42,7 +42,7 @@ from homeassistant.core import (
callback, callback,
) )
from homeassistant.helpers import condition, config_validation as cv 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 ( from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback, AddConfigEntryEntitiesCallback,
AddEntitiesCallback, AddEntitiesCallback,
@ -145,22 +145,22 @@ async def _async_setup_config(
[ [
GenericHygrostat( GenericHygrostat(
hass, hass,
name, name=name,
switch_entity_id, switch_entity_id=switch_entity_id,
sensor_entity_id, sensor_entity_id=sensor_entity_id,
min_humidity, min_humidity=min_humidity,
max_humidity, max_humidity=max_humidity,
target_humidity, target_humidity=target_humidity,
device_class, device_class=device_class,
min_cycle_duration, min_cycle_duration=min_cycle_duration,
dry_tolerance, dry_tolerance=dry_tolerance,
wet_tolerance, wet_tolerance=wet_tolerance,
keep_alive, keep_alive=keep_alive,
initial_state, initial_state=initial_state,
away_humidity, away_humidity=away_humidity,
away_fixed, away_fixed=away_fixed,
sensor_stale_duration, sensor_stale_duration=sensor_stale_duration,
unique_id, unique_id=unique_id,
) )
] ]
) )
@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
*,
name: str, name: str,
switch_entity_id: str, switch_entity_id: str,
sensor_entity_id: str, sensor_entity_id: str,
@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
self._name = name self._name = name
self._switch_entity_id = switch_entity_id self._switch_entity_id = switch_entity_id
self._sensor_entity_id = sensor_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, hass,
switch_entity_id, switch_entity_id,
) )

View File

@ -1,5 +1,7 @@
"""The generic_thermostat component.""" """The generic_thermostat component."""
import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import entity_registry as er 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, async_remove_stale_devices_links_keep_entity_device,
) )
from homeassistant.helpers.event import async_track_entity_registry_updated_event 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 from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry.""" """Set up from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device( async_remove_stale_devices_links_keep_entity_device(
hass, hass,
entry.entry_id, 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}, 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( entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the heater, but # 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 # not the temperature sensor because the generic_hygrostat adds itself to the
# heater's device. # heater's device.
async_handle_source_entity_changes( async_handle_source_entity_changes(
hass, hass,
add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id, helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id( source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_HEATER] hass, entry.options[CONF_HEATER]
), ),
source_entity_id_or_uuid=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 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: async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed.""" """Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@ -48,7 +48,7 @@ from homeassistant.core import (
) )
from homeassistant.exceptions import ConditionError from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv 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 ( from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback, AddConfigEntryEntitiesCallback,
AddEntitiesCallback, AddEntitiesCallback,
@ -182,23 +182,23 @@ async def _async_setup_config(
[ [
GenericThermostat( GenericThermostat(
hass, hass,
name, name=name,
heater_entity_id, heater_entity_id=heater_entity_id,
sensor_entity_id, sensor_entity_id=sensor_entity_id,
min_temp, min_temp=min_temp,
max_temp, max_temp=max_temp,
target_temp, target_temp=target_temp,
ac_mode, ac_mode=ac_mode,
min_cycle_duration, min_cycle_duration=min_cycle_duration,
cold_tolerance, cold_tolerance=cold_tolerance,
hot_tolerance, hot_tolerance=hot_tolerance,
keep_alive, keep_alive=keep_alive,
initial_hvac_mode, initial_hvac_mode=initial_hvac_mode,
presets, presets=presets,
precision, precision=precision,
target_temperature_step, target_temperature_step=target_temperature_step,
unit, unit=unit,
unique_id, unique_id=unique_id,
) )
] ]
) )
@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
*,
name: str, name: str,
heater_entity_id: str, heater_entity_id: str,
sensor_entity_id: str, sensor_entity_id: str,
@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self._attr_name = name self._attr_name = name
self.heater_entity_id = heater_entity_id self.heater_entity_id = heater_entity_id
self.sensor_entity_id = sensor_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, hass,
heater_entity_id, heater_entity_id,
) )

View File

@ -100,6 +100,8 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow.""" """Handle a config or options flow."""
MINOR_VERSION = 2
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW

View File

@ -7,6 +7,7 @@ from typing import Final
import voluptuous as vol 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.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE
from homeassistant.core import ( from homeassistant.core import (
CALLBACK_TYPE, CALLBACK_TYPE,
@ -17,7 +18,7 @@ from homeassistant.core import (
State, State,
callback, 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.config_validation import entity_domain
from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
@ -79,9 +80,11 @@ async def async_attach_trigger(
return return
from_match = ( 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 ( if (trigger_event == EVENT_ENTER and not from_match and to_match) or (
trigger_event == EVENT_LEAVE and from_match and not to_match trigger_event == EVENT_LEAVE and from_match and not to_match

View File

@ -19,6 +19,8 @@ API_TIMEOUT: Final = 30
ATTR_C6H6: Final = "c6h6" ATTR_C6H6: Final = "c6h6"
ATTR_CO: Final = "co" ATTR_CO: Final = "co"
ATTR_NO: Final = "no"
ATTR_NOX: Final = "nox"
ATTR_NO2: Final = "no2" ATTR_NO2: Final = "no2"
ATTR_O3: Final = "o3" ATTR_O3: Final = "o3"
ATTR_PM10: Final = "pm10" ATTR_PM10: Final = "pm10"

View File

@ -13,6 +13,9 @@
"no2_index": { "no2_index": {
"default": "mdi:molecule" "default": "mdi:molecule"
}, },
"nox": {
"default": "mdi:molecule"
},
"o3_index": { "o3_index": {
"default": "mdi:molecule" "default": "mdi:molecule"
}, },

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["dacite", "gios"], "loggers": ["dacite", "gios"],
"requirements": ["gios==6.1.0"] "requirements": ["gios==6.1.1"]
} }

View File

@ -27,7 +27,9 @@ from .const import (
ATTR_AQI, ATTR_AQI,
ATTR_C6H6, ATTR_C6H6,
ATTR_CO, ATTR_CO,
ATTR_NO,
ATTR_NO2, ATTR_NO2,
ATTR_NOX,
ATTR_O3, ATTR_O3,
ATTR_PM10, ATTR_PM10,
ATTR_PM25, ATTR_PM25,
@ -74,6 +76,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
translation_key="co", 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( GiosSensorEntityDescription(
key=ATTR_NO2, key=ATTR_NO2,
value=lambda sensors: sensors.no2.value if sensors.no2 else None, 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"], options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"],
translation_key="no2_index", 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( GiosSensorEntityDescription(
key=ATTR_O3, key=ATTR_O3,
value=lambda sensors: sensors.o3.value if sensors.o3 else None, value=lambda sensors: sensors.o3.value if sensors.o3 else None,

View File

@ -77,6 +77,9 @@
} }
} }
}, },
"nox": {
"name": "Nitrogen oxides"
},
"o3_index": { "o3_index": {
"name": "Ozone index", "name": "Ozone index",
"state": { "state": {

View File

@ -1,6 +1,7 @@
"""The Goodwe inverter component.""" """The Goodwe inverter component."""
from goodwe import InverterError, connect from goodwe import InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -20,11 +21,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
try: try:
inverter = await connect( inverter = await connect(
host=host, host=host,
port=GOODWE_UDP_PORT,
family=model_family, family=model_family,
retries=10, retries=10,
) )
except InverterError as err: except InverterError as err_udp:
raise ConfigEntryNotReady from err # 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( device_info = DeviceInfo(
configuration_url="https://www.semsportal.com", configuration_url="https://www.semsportal.com",

View File

@ -6,6 +6,7 @@ import logging
from typing import Any from typing import Any
from goodwe import InverterError, connect from goodwe import InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -27,6 +28,18 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -34,22 +47,19 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
errors = {} errors = {}
if user_input is not None: if user_input is not None:
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
try: try:
inverter = await connect(host=host, retries=10) inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
except InverterError: 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: else:
await self.async_set_unique_id(inverter.serial_number) return await self._handle_successful_connection(inverter, host)
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 self.async_show_form( return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors step_id="user", data_schema=CONFIG_SCHEMA, errors=errors

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/goodwe", "documentation": "https://www.home-assistant.io/integrations/goodwe",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["goodwe"], "loggers": ["goodwe"],
"requirements": ["goodwe==0.3.6"] "requirements": ["goodwe==0.4.8"]
} }

View File

@ -54,17 +54,24 @@ async def async_setup_entry(
# Inverter model does not support this setting # Inverter model does not support this setting
_LOGGER.debug("Could not read inverter operation mode") _LOGGER.debug("Could not read inverter operation mode")
else: else:
async_add_entities( active_mode_option = _MODE_TO_OPTION.get(active_mode)
[ if active_mode_option is not None:
InverterOperationModeEntity( async_add_entities(
device_info, [
OPERATION_MODE, InverterOperationModeEntity(
inverter, device_info,
[v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], OPERATION_MODE,
_MODE_TO_OPTION[active_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): class InverterOperationModeEntity(SelectEntity):

View File

@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
try: try:
responses = await self._client.streaming_recognize( responses = await self._client.streaming_recognize(
requests=request_generator(), requests=request_generator(),
timeout=10, timeout=30,
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
) )

View File

@ -218,7 +218,7 @@ class BaseGoogleCloudProvider:
response = await self._client.synthesize_speech( response = await self._client.synthesize_speech(
request, request,
timeout=10, timeout=30,
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
) )

View File

@ -195,11 +195,15 @@ async def async_update_options(
async def async_migrate_integration(hass: HomeAssistant) -> None: async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure.""" """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): if not any(entry.version == 1 for entry in entries):
return return
api_keys_entries: dict[str, ConfigEntry] = {} api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
device_registry = dr.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: if entry.data[CONF_API_KEY] not in api_keys_entries:
use_existing = True 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) hass.config_entries.async_add_subentry(parent_entry, subentry)
if use_existing: if use_existing:
@ -228,25 +237,51 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
unique_id=None, unique_id=None,
), ),
) )
conversation_entity = entity_registry.async_get_entity_id( conversation_entity_id = entity_registry.async_get_entity_id(
"conversation", "conversation",
DOMAIN, DOMAIN,
entry.entry_id, 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( device = device_registry.async_get_device(
identifiers={(DOMAIN, entry.entry_id)} 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: 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_registry.async_update_device(
device.id, device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)}, new_identifiers={(DOMAIN, subentry.subentry_id)},
add_config_subentry_id=subentry.subentry_id, add_config_subentry_id=subentry.subentry_id,
add_config_entry_id=parent_entry.entry_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: if not use_existing:
await hass.config_entries.async_remove(entry.entry_id) await hass.config_entries.async_remove(entry.entry_id)
else: else:
_add_ai_task_subentry(hass, entry)
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, entry,
title=DEFAULT_TITLE, title=DEFAULT_TITLE,
options={}, options={},
version=2, 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: if entry.version == 2 and entry.minor_version == 2:
# Add AI Task subentry with default options # Add AI Task subentry with default options
hass.config_entries.async_add_subentry( _add_ai_task_subentry(hass, entry)
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
subentry_type="ai_task_data",
title=DEFAULT_AI_TASK_NAME,
unique_id=None,
),
)
hass.config_entries.async_update_entry(entry, minor_version=3) 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( LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version "Migration to version %s:%s successful", entry.version, entry.minor_version
) )
return True 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,
),
)

View File

@ -37,7 +37,10 @@ class GoogleGenerativeAITaskEntity(
): ):
"""Google Generative AI AI Task entity.""" """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( async def _async_generate_data(
self, self,

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