Merge branch 'dev' into mqtt-subentry-entity_category

This commit is contained in:
Jan Bouwhuis
2025-06-18 18:35:45 +02:00
committed by GitHub
244 changed files with 6596 additions and 3243 deletions

View File

@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v10 uses: dawidd6/action-download-artifact@v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents - name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v10 uses: dawidd6/action-download-artifact@v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package repo: home-assistant/intents-package

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.2"] "requirements": ["aioamazondevices==3.1.12"]
} }

View File

@@ -366,11 +366,11 @@ class AnthropicConversationEntity(
options = self.entry.options options = self.entry.options
try: try:
await chat_log.async_update_llm_data( await chat_log.async_provide_llm_data(
DOMAIN, user_input.as_llm_context(DOMAIN),
user_input,
options.get(CONF_LLM_HASS_API), options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT), options.get(CONF_PROMPT),
user_input.extra_system_prompt,
) )
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()

View File

@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfApparentPower, UnitOfApparentPower,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
@@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"alarmdel": SensorEntityDescription( "alarmdel": SensorEntityDescription(
key="alarmdel", key="alarmdel",
translation_key="alarm_delay", translation_key="alarm_delay",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"ambtemp": SensorEntityDescription( "ambtemp": SensorEntityDescription(
key="ambtemp", key="ambtemp",
@@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="apc", key="apc",
translation_key="apc_status", translation_key="apc_status",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"apcmodel": SensorEntityDescription( "apcmodel": SensorEntityDescription(
key="apcmodel", key="apcmodel",
translation_key="apc_model", translation_key="apc_model",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"badbatts": SensorEntityDescription( "badbatts": SensorEntityDescription(
key="badbatts", key="badbatts",
translation_key="bad_batteries", translation_key="bad_batteries",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"battdate": SensorEntityDescription( "battdate": SensorEntityDescription(
key="battdate", key="battdate",
@@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="cable", key="cable",
translation_key="cable_type", translation_key="cable_type",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"cumonbatt": SensorEntityDescription( "cumonbatt": SensorEntityDescription(
key="cumonbatt", key="cumonbatt",
@@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="date", key="date",
translation_key="date", translation_key="date",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"dipsw": SensorEntityDescription( "dipsw": SensorEntityDescription(
key="dipsw", key="dipsw",
translation_key="dip_switch_settings", translation_key="dip_switch_settings",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"dlowbatt": SensorEntityDescription( "dlowbatt": SensorEntityDescription(
key="dlowbatt", key="dlowbatt",
translation_key="low_battery_signal", translation_key="low_battery_signal",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"driver": SensorEntityDescription( "driver": SensorEntityDescription(
key="driver", key="driver",
translation_key="driver", translation_key="driver",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"dshutd": SensorEntityDescription( "dshutd": SensorEntityDescription(
key="dshutd", key="dshutd",
translation_key="shutdown_delay", translation_key="shutdown_delay",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"dwake": SensorEntityDescription( "dwake": SensorEntityDescription(
key="dwake", key="dwake",
translation_key="wake_delay", translation_key="wake_delay",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"end apc": SensorEntityDescription( "end apc": SensorEntityDescription(
key="end apc", key="end apc",
translation_key="date_and_time", translation_key="date_and_time",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"extbatts": SensorEntityDescription( "extbatts": SensorEntityDescription(
key="extbatts", key="extbatts",
translation_key="external_batteries", translation_key="external_batteries",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"firmware": SensorEntityDescription( "firmware": SensorEntityDescription(
key="firmware", key="firmware",
translation_key="firmware_version", translation_key="firmware_version",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"hitrans": SensorEntityDescription( "hitrans": SensorEntityDescription(
key="hitrans", key="hitrans",
translation_key="transfer_high", translation_key="transfer_high",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"hostname": SensorEntityDescription( "hostname": SensorEntityDescription(
key="hostname", key="hostname",
translation_key="hostname", translation_key="hostname",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"humidity": SensorEntityDescription( "humidity": SensorEntityDescription(
key="humidity", key="humidity",
@@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="lastxfer", key="lastxfer",
translation_key="last_transfer", translation_key="last_transfer",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"linefail": SensorEntityDescription( "linefail": SensorEntityDescription(
key="linefail", key="linefail",
translation_key="line_failure", translation_key="line_failure",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"linefreq": SensorEntityDescription( "linefreq": SensorEntityDescription(
key="linefreq", key="linefreq",
@@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="transfer_low", translation_key="transfer_low",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"mandate": SensorEntityDescription( "mandate": SensorEntityDescription(
key="mandate", key="mandate",
translation_key="manufacture_date", translation_key="manufacture_date",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"masterupd": SensorEntityDescription( "masterupd": SensorEntityDescription(
key="masterupd", key="masterupd",
translation_key="master_update", translation_key="master_update",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"maxlinev": SensorEntityDescription( "maxlinev": SensorEntityDescription(
key="maxlinev", key="maxlinev",
@@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = {
"maxtime": SensorEntityDescription( "maxtime": SensorEntityDescription(
key="maxtime", key="maxtime",
translation_key="max_time", translation_key="max_time",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"mbattchg": SensorEntityDescription( "mbattchg": SensorEntityDescription(
key="mbattchg", key="mbattchg",
translation_key="max_battery_charge", translation_key="max_battery_charge",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"minlinev": SensorEntityDescription( "minlinev": SensorEntityDescription(
key="minlinev", key="minlinev",
@@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = {
"mintimel": SensorEntityDescription( "mintimel": SensorEntityDescription(
key="mintimel", key="mintimel",
translation_key="min_time", translation_key="min_time",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"model": SensorEntityDescription( "model": SensorEntityDescription(
key="model", key="model",
translation_key="model", translation_key="model",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"nombattv": SensorEntityDescription( "nombattv": SensorEntityDescription(
key="nombattv", key="nombattv",
translation_key="battery_nominal_voltage", translation_key="battery_nominal_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"nominv": SensorEntityDescription( "nominv": SensorEntityDescription(
key="nominv", key="nominv",
translation_key="nominal_input_voltage", translation_key="nominal_input_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"nomoutv": SensorEntityDescription( "nomoutv": SensorEntityDescription(
key="nomoutv", key="nomoutv",
translation_key="nominal_output_voltage", translation_key="nominal_output_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"nompower": SensorEntityDescription( "nompower": SensorEntityDescription(
key="nompower", key="nompower",
translation_key="nominal_output_power", translation_key="nominal_output_power",
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"nomapnt": SensorEntityDescription( "nomapnt": SensorEntityDescription(
key="nomapnt", key="nomapnt",
translation_key="nominal_apparent_power", translation_key="nominal_apparent_power",
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
device_class=SensorDeviceClass.APPARENT_POWER, device_class=SensorDeviceClass.APPARENT_POWER,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"numxfers": SensorEntityDescription( "numxfers": SensorEntityDescription(
key="numxfers", key="numxfers",
@@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="reg1", key="reg1",
translation_key="register_1_fault", translation_key="register_1_fault",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"reg2": SensorEntityDescription( "reg2": SensorEntityDescription(
key="reg2", key="reg2",
translation_key="register_2_fault", translation_key="register_2_fault",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"reg3": SensorEntityDescription( "reg3": SensorEntityDescription(
key="reg3", key="reg3",
translation_key="register_3_fault", translation_key="register_3_fault",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"retpct": SensorEntityDescription( "retpct": SensorEntityDescription(
key="retpct", key="retpct",
translation_key="restore_capacity", translation_key="restore_capacity",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"selftest": SensorEntityDescription( "selftest": SensorEntityDescription(
key="selftest", key="selftest",
@@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="sense", key="sense",
translation_key="sensitivity", translation_key="sensitivity",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"serialno": SensorEntityDescription( "serialno": SensorEntityDescription(
key="serialno", key="serialno",
translation_key="serial_number", translation_key="serial_number",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"starttime": SensorEntityDescription( "starttime": SensorEntityDescription(
key="starttime", key="starttime",
translation_key="startup_time", translation_key="startup_time",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"statflag": SensorEntityDescription( "statflag": SensorEntityDescription(
key="statflag", key="statflag",
translation_key="online_status", translation_key="online_status",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"status": SensorEntityDescription( "status": SensorEntityDescription(
key="status", key="status",
@@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"stesti": SensorEntityDescription( "stesti": SensorEntityDescription(
key="stesti", key="stesti",
translation_key="self_test_interval", translation_key="self_test_interval",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"timeleft": SensorEntityDescription( "timeleft": SensorEntityDescription(
key="timeleft", key="timeleft",
@@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="upsname", key="upsname",
translation_key="ups_name", translation_key="ups_name",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"version": SensorEntityDescription( "version": SensorEntityDescription(
key="version", key="version",
translation_key="version", translation_key="version",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"xoffbat": SensorEntityDescription( "xoffbat": SensorEntityDescription(
key="xoffbat", key="xoffbat",
translation_key="transfer_from_battery", translation_key="transfer_from_battery",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"xoffbatt": SensorEntityDescription( "xoffbatt": SensorEntityDescription(
key="xoffbatt", key="xoffbatt",
translation_key="transfer_from_battery", translation_key="transfer_from_battery",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"xonbatt": SensorEntityDescription( "xonbatt": SensorEntityDescription(
key="xonbatt", key="xonbatt",
translation_key="transfer_to_battery", translation_key="transfer_to_battery",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
} }

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome", "documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bthome-ble==3.12.4"] "requirements": ["bthome-ble==3.13.1"]
} }

View File

@@ -105,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
# Can be removed in 2025.1 after deprecation period of the new feature flags
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
)
SET_TEMPERATURE_SCHEMA = vol.All( SET_TEMPERATURE_SCHEMA = vol.All(
cv.has_at_least_one_key( cv.has_at_least_one_key(
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW

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.101.0"], "requirements": ["hass-nabucasa==0.102.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -14,12 +14,11 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import chat_session, intent, llm, template from homeassistant.helpers import chat_session, frame, intent, llm, template
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType from homeassistant.util.json import JsonObjectType
from . import trace from . import trace
from .const import DOMAIN
from .models import ConversationInput, ConversationResult from .models import ConversationInput, ConversationResult
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
@@ -359,7 +358,7 @@ class ChatLog:
self, self,
llm_context: llm.LLMContext, llm_context: llm.LLMContext,
prompt: str, prompt: str,
language: str, language: str | None,
user_name: str | None = None, user_name: str | None = None,
) -> str: ) -> str:
try: try:
@@ -373,7 +372,7 @@ class ChatLog:
) )
except TemplateError as err: except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err) LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=language) intent_response = intent.IntentResponse(language=language or "")
intent_response.async_set_error( intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN, intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template", "Sorry, I had a problem with my template",
@@ -392,15 +391,25 @@ class ChatLog:
user_llm_prompt: str | None = None, user_llm_prompt: str | None = None,
) -> None: ) -> None:
"""Set the LLM system prompt.""" """Set the LLM system prompt."""
llm_context = llm.LLMContext( frame.report_usage(
platform=conversing_domain, "ChatLog.async_update_llm_data",
context=user_input.context, breaks_in_ha_version="2026.1",
user_prompt=user_input.text, )
language=user_input.language, return await self.async_provide_llm_data(
assistant=DOMAIN, llm_context=user_input.as_llm_context(conversing_domain),
device_id=user_input.device_id, user_llm_hass_api=user_llm_hass_api,
user_llm_prompt=user_llm_prompt,
user_extra_system_prompt=user_input.extra_system_prompt,
) )
async def async_provide_llm_data(
self,
llm_context: llm.LLMContext,
user_llm_hass_api: str | list[str] | None = None,
user_llm_prompt: str | None = None,
user_extra_system_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None llm_api: llm.APIInstance | None = None
if user_llm_hass_api: if user_llm_hass_api:
@@ -414,10 +423,12 @@ class ChatLog:
LOGGER.error( LOGGER.error(
"Error getting LLM API %s for %s: %s", "Error getting LLM API %s for %s: %s",
user_llm_hass_api, user_llm_hass_api,
conversing_domain, llm_context.platform,
err, err,
) )
intent_response = intent.IntentResponse(language=user_input.language) intent_response = intent.IntentResponse(
language=llm_context.language or ""
)
intent_response.async_set_error( intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN, intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API", "Error preparing LLM API",
@@ -431,10 +442,10 @@ class ChatLog:
user_name: str | None = None user_name: str | None = None
if ( if (
user_input.context llm_context.context
and user_input.context.user_id and llm_context.context.user_id
and ( and (
user := await self.hass.auth.async_get_user(user_input.context.user_id) user := await self.hass.auth.async_get_user(llm_context.context.user_id)
) )
): ):
user_name = user.name user_name = user.name
@@ -444,7 +455,7 @@ class ChatLog:
await self._async_expand_prompt_template( await self._async_expand_prompt_template(
llm_context, llm_context,
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
user_input.language, llm_context.language,
user_name, user_name,
) )
) )
@@ -456,14 +467,14 @@ class ChatLog:
await self._async_expand_prompt_template( await self._async_expand_prompt_template(
llm_context, llm_context,
llm.BASE_PROMPT, llm.BASE_PROMPT,
user_input.language, llm_context.language,
user_name, user_name,
) )
) )
if extra_system_prompt := ( if extra_system_prompt := (
# Take new system prompt if one was given # Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt user_extra_system_prompt or self.extra_system_prompt
): ):
prompt_parts.append(extra_system_prompt) prompt_parts.append(extra_system_prompt)

View File

@@ -7,7 +7,9 @@ from dataclasses import dataclass
from typing import Any, Literal from typing import Any, Literal
from homeassistant.core import Context from homeassistant.core import Context
from homeassistant.helpers import intent from homeassistant.helpers import intent, llm
from .const import DOMAIN
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -56,6 +58,16 @@ class ConversationInput:
"extra_system_prompt": self.extra_system_prompt, "extra_system_prompt": self.extra_system_prompt,
} }
def as_llm_context(self, conversing_domain: str) -> llm.LLMContext:
"""Return input as an LLM context."""
return llm.LLMContext(
platform=conversing_domain,
context=self.context,
language=self.language,
assistant=DOMAIN,
device_id=self.device_id,
)
@dataclass(slots=True) @dataclass(slots=True)
class ConversationResult: class ConversationResult:

View File

@@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> CoverEntityFeature: def supported_features(self) -> CoverEntityFeature:
"""Flag supported features.""" """Flag supported features."""
if (features := self._attr_supported_features) is not None: if (features := self._attr_supported_features) is not None:
if type(features) is int:
new_features = CoverEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features return features
supported_features = ( supported_features = (

View File

@@ -91,7 +91,9 @@ async def async_unload_entry(
async def async_remove_config_entry_device( async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry hass: HomeAssistant,
config_entry: DevoloHomeControlConfigEntry,
device_entry: DeviceEntry,
) -> bool: ) -> bool:
"""Remove a config entry from a device.""" """Remove a config entry from a device."""
return True return True

View File

@@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
): ):
"""Representation of a devolo device tracker.""" """Representation of a devolo device tracker."""
_attr_has_entity_name = True
_attr_translation_key = "device_tracker" _attr_translation_key = "device_tracker"
def __init__( def __init__(
@@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
super().__init__(coordinator) super().__init__(coordinator)
self._device = device self._device = device
self._attr_mac_address = mac self._attr_mac_address = mac
self._attr_name = mac
@property @property
def extra_state_attributes(self) -> dict[str, str]: def extra_state_attributes(self) -> dict[str, str]:

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["discord"], "loggers": ["discord"],
"requirements": ["nextcord==2.6.0"] "requirements": ["nextcord==3.1.0"]
} }

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["sml"], "loggers": ["sml"],
"requirements": ["pysml==0.0.12"] "requirements": ["pysml==0.1.5"]
} }

View File

@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from eq3btsmart import Thermostat from eq3btsmart import Thermostat
from eq3btsmart.exceptions import Eq3Exception from eq3btsmart.exceptions import Eq3Exception
from eq3btsmart.thermostat_config import ThermostatConfig
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
f"[{eq3_config.mac_address}] Device could not be found" f"[{eq3_config.mac_address}] Device could not be found"
) )
thermostat = Thermostat( thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
thermostat_config=ThermostatConfig(
mac_address=mac_address,
),
ble_device=device,
)
entry.runtime_data = Eq3ConfigEntryData( entry.runtime_data = Eq3ConfigEntryData(
eq3_config=eq3_config, thermostat=thermostat eq3_config=eq3_config, thermostat=thermostat

View File

@@ -2,7 +2,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart.models import Status from eq3btsmart.models import Status
@@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the state of the binary sensor.""" """Return the state of the binary sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status) return self.entity_description.value_func(self._thermostat.status)

View File

@@ -1,9 +1,16 @@
"""Platform for eQ-3 climate entities.""" """Platform for eQ-3 climate entities."""
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode from eq3btsmart.const import (
EQ3_DEFAULT_AWAY_TEMP,
EQ3_MAX_TEMP,
EQ3_OFF_TEMP,
Eq3OperationMode,
Eq3Preset,
)
from eq3btsmart.exceptions import Eq3Exception from eq3btsmart.exceptions import Eq3Exception
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.util.dt as dt_util
from . import Eq3ConfigEntry from . import Eq3ConfigEntry
from .const import ( from .const import (
DEFAULT_AWAY_HOURS,
EQ_TO_HA_HVAC, EQ_TO_HA_HVAC,
HA_TO_EQ_HVAC, HA_TO_EQ_HVAC,
CurrentTemperatureSelector, CurrentTemperatureSelector,
@@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
| ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_ON
) )
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = EQ3BT_OFF_TEMP _attr_min_temp = EQ3_OFF_TEMP
_attr_max_temp = EQ3BT_MAX_TEMP _attr_max_temp = EQ3_MAX_TEMP
_attr_precision = PRECISION_HALVES _attr_precision = PRECISION_HALVES
_attr_hvac_modes = list(HA_TO_EQ_HVAC.keys()) _attr_hvac_modes = list(HA_TO_EQ_HVAC.keys())
_attr_preset_modes = list(Preset) _attr_preset_modes = list(Preset)
@@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
_target_temperature: float | None = None _target_temperature: float | None = None
@callback @callback
def _async_on_updated(self) -> None: def _async_on_status_updated(self, data: Any) -> None:
"""Handle updated data from the thermostat."""
if self._thermostat.status is not None:
self._async_on_status_updated()
if self._thermostat.device_data is not None:
self._async_on_device_updated()
super()._async_on_updated()
@callback
def _async_on_status_updated(self) -> None:
"""Handle updated status from the thermostat.""" """Handle updated status from the thermostat."""
if self._thermostat.status is None: self._target_temperature = self._thermostat.status.target_temperature
return
self._target_temperature = self._thermostat.status.target_temperature.value
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
self._attr_current_temperature = self._get_current_temperature() self._attr_current_temperature = self._get_current_temperature()
self._attr_target_temperature = self._get_target_temperature() self._attr_target_temperature = self._get_target_temperature()
self._attr_preset_mode = self._get_current_preset_mode() self._attr_preset_mode = self._get_current_preset_mode()
self._attr_hvac_action = self._get_current_hvac_action() self._attr_hvac_action = self._get_current_hvac_action()
super()._async_on_status_updated(data)
@callback @callback
def _async_on_device_updated(self) -> None: def _async_on_device_updated(self, data: Any) -> None:
"""Handle updated device data from the thermostat.""" """Handle updated device data from the thermostat."""
if self._thermostat.device_data is None:
return
device_registry = dr.async_get(self.hass) device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device( if device := device_registry.async_get_device(
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
@@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
device_registry.async_update_device( device_registry.async_update_device(
device.id, device.id,
sw_version=str(self._thermostat.device_data.firmware_version), sw_version=str(self._thermostat.device_data.firmware_version),
serial_number=self._thermostat.device_data.device_serial.value, serial_number=self._thermostat.device_data.device_serial,
) )
super()._async_on_device_updated(data)
def _get_current_temperature(self) -> float | None: def _get_current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
@@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case CurrentTemperatureSelector.NOTHING: case CurrentTemperatureSelector.NOTHING:
return None return None
case CurrentTemperatureSelector.VALVE: case CurrentTemperatureSelector.VALVE:
if self._thermostat.status is None:
return None
return float(self._thermostat.status.valve_temperature) return float(self._thermostat.status.valve_temperature)
case CurrentTemperatureSelector.UI: case CurrentTemperatureSelector.UI:
return self._target_temperature return self._target_temperature
case CurrentTemperatureSelector.DEVICE: case CurrentTemperatureSelector.DEVICE:
if self._thermostat.status is None: return float(self._thermostat.status.target_temperature)
return None
return float(self._thermostat.status.target_temperature.value)
case CurrentTemperatureSelector.ENTITY: case CurrentTemperatureSelector.ENTITY:
state = self.hass.states.get(self._eq3_config.external_temp_sensor) state = self.hass.states.get(self._eq3_config.external_temp_sensor)
if state is not None: if state is not None:
@@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case TargetTemperatureSelector.TARGET: case TargetTemperatureSelector.TARGET:
return self._target_temperature return self._target_temperature
case TargetTemperatureSelector.LAST_REPORTED: case TargetTemperatureSelector.LAST_REPORTED:
if self._thermostat.status is None: return float(self._thermostat.status.target_temperature)
return None
return float(self._thermostat.status.target_temperature.value)
def _get_current_preset_mode(self) -> str: def _get_current_preset_mode(self) -> str:
"""Return the current preset mode.""" """Return the current preset mode."""
if (status := self._thermostat.status) is None: status = self._thermostat.status
return PRESET_NONE
if status.is_window_open: if status.is_window_open:
return Preset.WINDOW_OPEN return Preset.WINDOW_OPEN
if status.is_boost: if status.is_boost:
@@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
return Preset.LOW_BATTERY return Preset.LOW_BATTERY
if status.is_away: if status.is_away:
return Preset.AWAY return Preset.AWAY
if status.operation_mode is OperationMode.ON: if status.operation_mode is Eq3OperationMode.ON:
return Preset.OPEN return Preset.OPEN
if status.presets is None: if status.presets is None:
return PRESET_NONE return PRESET_NONE
@@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
def _get_current_hvac_action(self) -> HVACAction: def _get_current_hvac_action(self) -> HVACAction:
"""Return the current hvac action.""" """Return the current hvac action."""
if ( if self._thermostat.status.operation_mode is Eq3OperationMode.OFF:
self._thermostat.status is None
or self._thermostat.status.operation_mode is OperationMode.OFF
):
return HVACAction.OFF return HVACAction.OFF
if self._thermostat.status.valve == 0: if self._thermostat.status.valve == 0:
return HVACAction.IDLE return HVACAction.IDLE
@@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
"""Set new target hvac mode.""" """Set new target hvac mode."""
if hvac_mode is HVACMode.OFF: if hvac_mode is HVACMode.OFF:
await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP) await self.async_set_temperature(temperature=EQ3_OFF_TEMP)
try: try:
await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode])
@@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case Preset.BOOST: case Preset.BOOST:
await self._thermostat.async_set_boost(True) await self._thermostat.async_set_boost(True)
case Preset.AWAY: case Preset.AWAY:
await self._thermostat.async_set_away(True) away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
case Preset.ECO: case Preset.ECO:
await self._thermostat.async_set_preset(Eq3Preset.ECO) await self._thermostat.async_set_preset(Eq3Preset.ECO)
case Preset.COMFORT: case Preset.COMFORT:
await self._thermostat.async_set_preset(Eq3Preset.COMFORT) await self._thermostat.async_set_preset(Eq3Preset.COMFORT)
case Preset.OPEN: case Preset.OPEN:
await self._thermostat.async_set_mode(OperationMode.ON) await self._thermostat.async_set_mode(Eq3OperationMode.ON)

View File

@@ -2,7 +2,7 @@
from enum import Enum from enum import Enum
from eq3btsmart.const import OperationMode from eq3btsmart.const import Eq3OperationMode
from homeassistant.components.climate import ( from homeassistant.components.climate import (
PRESET_AWAY, PRESET_AWAY,
@@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until"
GET_DEVICE_TIMEOUT = 5 # seconds GET_DEVICE_TIMEOUT = 5 # seconds
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = {
OperationMode.OFF: HVACMode.OFF, Eq3OperationMode.OFF: HVACMode.OFF,
OperationMode.ON: HVACMode.HEAT, Eq3OperationMode.ON: HVACMode.HEAT,
OperationMode.AUTO: HVACMode.AUTO, Eq3OperationMode.AUTO: HVACMode.AUTO,
OperationMode.MANUAL: HVACMode.HEAT, Eq3OperationMode.MANUAL: HVACMode.HEAT,
} }
HA_TO_EQ_HVAC = { HA_TO_EQ_HVAC = {
HVACMode.OFF: OperationMode.OFF, HVACMode.OFF: Eq3OperationMode.OFF,
HVACMode.AUTO: OperationMode.AUTO, HVACMode.AUTO: Eq3OperationMode.AUTO,
HVACMode.HEAT: OperationMode.MANUAL, HVACMode.HEAT: Eq3OperationMode.MANUAL,
} }
@@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum):
DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE
DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET
DEFAULT_SCAN_INTERVAL = 10 # seconds DEFAULT_SCAN_INTERVAL = 10 # seconds
DEFAULT_AWAY_HOURS = 30 * 24
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"

View File

@@ -1,5 +1,10 @@
"""Base class for all eQ-3 entities.""" """Base class for all eQ-3 entities."""
from typing import Any
from eq3btsmart import Eq3Exception
from eq3btsmart.const import Eq3Event
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH, CONNECTION_BLUETOOTH,
@@ -45,7 +50,15 @@ class Eq3Entity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated) self._thermostat.register_callback(
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
)
self._thermostat.register_callback(
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
)
self._thermostat.register_callback(
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
)
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
@@ -65,10 +78,25 @@ class Eq3Entity(Entity):
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.""" """Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated) self._thermostat.unregister_callback(
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
)
self._thermostat.unregister_callback(
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
)
self._thermostat.unregister_callback(
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
)
def _async_on_updated(self) -> None: @callback
"""Handle updated data from the thermostat.""" def _async_on_status_updated(self, data: Any) -> None:
"""Handle updated status from the thermostat."""
self.async_write_ha_state()
@callback
def _async_on_device_updated(self, data: Any) -> None:
"""Handle updated device data from the thermostat."""
self.async_write_ha_state() self.async_write_ha_state()
@@ -90,4 +118,9 @@ class Eq3Entity(Entity):
def available(self) -> bool: def available(self) -> bool:
"""Whether the entity is available.""" """Whether the entity is available."""
return self._thermostat.status is not None and self._attr_available try:
_ = self._thermostat.status
except Eq3Exception:
return False
return self._attr_available

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==1.4.1", "bleak-esphome==2.16.0"] "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"]
} }

View File

@@ -1,17 +1,12 @@
"""Platform for eq3 number entities.""" """Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from eq3btsmart import Thermostat from eq3btsmart import Thermostat
from eq3btsmart.const import ( from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP
EQ3BT_MAX_OFFSET, from eq3btsmart.models import Presets, Status
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import ( from homeassistant.components.number import (
NumberDeviceClass, NumberDeviceClass,
@@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
value_func: Callable[[Presets], float] value_func: Callable[[Presets], float]
value_set_func: Callable[ value_set_func: Callable[
[Thermostat], [Thermostat],
Callable[[float], Awaitable[None]], Callable[[float], Coroutine[None, None, Status]],
] ]
mode: NumberMode = NumberMode.BOX mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG entity_category: EntityCategory | None = EntityCategory.CONFIG
@@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
NUMBER_ENTITY_DESCRIPTIONS = [ NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription( Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT, key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value, value_func=lambda presets: presets.comfort_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT, translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP, native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP, native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP, native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
), ),
Eq3NumberEntityDescription( Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO, key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value, value_func=lambda presets: presets.eco_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO, translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP, native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP, native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP, native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
), ),
Eq3NumberEntityDescription( Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value, value_func=lambda presets: presets.window_open_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP, native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP, native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP, native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
), ),
Eq3NumberEntityDescription( Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET, key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value, value_func=lambda presets: presets.offset_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET, translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET, native_min_value=EQ3_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET, native_max_value=EQ3_MAX_OFFSET,
native_step=EQ3BT_STEP, native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
@@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription( Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, value_func=lambda presets: presets.window_open_time.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0, native_min_value=0,
native_max_value=60, native_max_value=60,
@@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Return the state of the entity.""" """Return the state of the entity."""
if TYPE_CHECKING: if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets) return self.entity_description.value_func(self._thermostat.status.presets)
@@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Return whether the entity is available.""" """Return whether the entity is available."""
return ( return (
self._thermostat.status is not None super().available
and self._thermostat.status.presets is not None and self._thermostat.status.presets is not None
and self._attr_available and self._attr_available
) )

View File

@@ -1,12 +1,12 @@
"""Voluptuous schemas for eq3btsmart.""" """Voluptuous schemas for eq3btsmart."""
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_MAC from homeassistant.const import CONF_MAC
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP)
SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string}) SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string})
SCHEMA_MAC = vol.Schema( SCHEMA_MAC = vol.Schema(
{ {

View File

@@ -3,7 +3,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from eq3btsmart.models import Status from eq3btsmart.models import Status
@@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [
Eq3SensorEntityDescription( Eq3SensorEntityDescription(
key=ENTITY_KEY_AWAY_UNTIL, key=ENTITY_KEY_AWAY_UNTIL,
translation_key=ENTITY_KEY_AWAY_UNTIL, translation_key=ENTITY_KEY_AWAY_UNTIL,
value_func=lambda status: ( value_func=lambda status: (status.away_until if status.away_until else None),
status.away_until.value if status.away_until else None
),
device_class=SensorDeviceClass.DATE, device_class=SensorDeviceClass.DATE,
), ),
] ]
@@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity):
def native_value(self) -> int | datetime | None: def native_value(self) -> int | datetime | None:
"""Return the value reported by the sensor.""" """Return the value reported by the sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status) return self.entity_description.value_func(self._thermostat.status)

View File

@@ -1,26 +1,45 @@
"""Platform for eq3 switch entities.""" """Platform for eq3 switch entities."""
from collections.abc import Awaitable, Callable from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from datetime import timedelta
from functools import partial
from typing import Any
from eq3btsmart import Thermostat from eq3btsmart import Thermostat
from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode
from eq3btsmart.models import Status from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.util.dt as dt_util
from . import Eq3ConfigEntry from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK from .const import (
DEFAULT_AWAY_HOURS,
ENTITY_KEY_AWAY,
ENTITY_KEY_BOOST,
ENTITY_KEY_LOCK,
)
from .entity import Eq3Entity from .entity import Eq3Entity
async def async_set_away(thermostat: Thermostat, enable: bool) -> Status:
"""Backport old async_set_away behavior."""
if not enable:
return await thermostat.async_set_mode(Eq3OperationMode.AUTO)
away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Eq3SwitchEntityDescription(SwitchEntityDescription): class Eq3SwitchEntityDescription(SwitchEntityDescription):
"""Entity description for eq3 switch entities.""" """Entity description for eq3 switch entities."""
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]]
value_func: Callable[[Status], bool] value_func: Callable[[Status], bool]
@@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [
Eq3SwitchEntityDescription( Eq3SwitchEntityDescription(
key=ENTITY_KEY_AWAY, key=ENTITY_KEY_AWAY,
translation_key=ENTITY_KEY_AWAY, translation_key=ENTITY_KEY_AWAY,
toggle_func=lambda thermostat: thermostat.async_set_away, toggle_func=lambda thermostat: partial(async_set_away, thermostat),
value_func=lambda status: status.is_away, value_func=lambda status: status.is_away,
), ),
] ]
@@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the state of the switch.""" """Return the state of the switch."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status) return self.entity_description.value_func(self._thermostat.status)

View File

@@ -332,7 +332,7 @@ class EsphomeAssistSatellite(
} }
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
assert event.data is not None assert event.data is not None
if tts_output := event.data["tts_output"]: if tts_output := event.data.get("tts_output"):
path = tts_output["url"] path = tts_output["url"]
url = async_process_play_media_url(self.hass, path) url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url} data_to_send = {"url": url}

View File

@@ -109,6 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key="timestamp", key="timestamp",
translation_key="timestamp", translation_key="timestamp",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"]
} }

View File

@@ -45,7 +45,10 @@ CONF_IMAGE_FILENAME = "image_filename"
CONF_FILENAMES = "filenames" CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (Platform.CONVERSATION,) PLATFORMS = (
Platform.CONVERSATION,
Platform.TTS,
)
type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] type GoogleGenerativeAIConfigEntry = ConfigEntry[Client]

View File

@@ -6,9 +6,11 @@ DOMAIN = "google_generative_ai_conversation"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p" CONF_TOP_P = "top_p"

View File

@@ -2,63 +2,18 @@
from __future__ import annotations from __future__ import annotations
import codecs from typing import Literal
from collections.abc import AsyncGenerator, Callable
from dataclasses import replace
from typing import Any, Literal, cast
from google.genai.errors import APIError, ClientError
from google.genai.types import (
AutomaticFunctionCallingConfig,
Content,
FunctionDeclaration,
GenerateContentConfig,
GenerateContentResponse,
GoogleSearch,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation from homeassistant.components import assist_pipeline, conversation
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers import intent
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ( from .const import CONF_PROMPT, DOMAIN, LOGGER
CONF_CHAT_MODEL, from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
ERROR_GETTING_RESPONSE = (
"Sorry, I had a problem getting a response from Google Generative AI."
)
async def async_setup_entry( async def async_setup_entry(
@@ -71,267 +26,18 @@ async def async_setup_entry(
async_add_entities([agent]) async_add_entities([agent])
SUPPORTED_SCHEMA_KEYS = {
# Gemini API does not support all of the OpenAPI schema
# SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
"max_items",
"min_items",
"properties",
"required",
"items",
}
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
def _format_schema(schema: dict[str, Any]) -> Schema:
"""Format the schema to be compatible with Gemini API."""
if subschemas := schema.get("allOf"):
for subschema in subschemas: # Gemini API does not support allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
subschemas[0]
) # Or, if not found, to any of the subschemas
result = {}
for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "type":
val = val.upper()
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type": "STRING"}}
result["required"] = []
return cast(Schema, result)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> Tool:
"""Format tool specification."""
if tool.parameters.schema:
parameters = _format_schema(
convert(tool.parameters, custom_serializer=custom_serializer)
)
else:
parameters = None
return Tool(
function_declarations=[
FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters=parameters,
)
]
)
def _escape_decode(value: Any) -> Any:
"""Recursively call codecs.escape_decode on all values."""
if isinstance(value, str):
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
return {k: _escape_decode(v) for k, v in value.items()}
return value
def _create_google_tool_response_parts(
parts: list[conversation.ToolResultContent],
) -> list[Part]:
"""Create Google tool response parts."""
return [
Part.from_function_response(
name=tool_result.tool_name, response=tool_result.tool_result
)
for tool_result in parts
]
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> Content:
"""Create a Google tool response content."""
return Content(
role="user",
parts=_create_google_tool_response_parts(content),
)
def _convert_content(
content: (
conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent
),
) -> Content:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
if content.content:
parts.append(Part.from_text(text=content.content))
if content.tool_calls:
parts.extend(
[
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
return Content(role="model", parts=parts)
async def _transform_stream(
result: AsyncGenerator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
chunk["role"] = "assistant"
new_message = False
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
reason = (
response.prompt_feedback.block_reason_message
if response.prompt_feedback
else "unknown"
)
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {reason}"
)
candidate = response.candidates[0]
if (
candidate.finish_reason is not None
and candidate.finish_reason != "STOP"
):
# The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason
LOGGER.error(
"Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason",
candidate.finish_reason,
)
raise HomeAssistantError(
f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}"
)
response_parts = (
candidate.content.parts
if candidate.content is not None and candidate.content.parts is not None
else []
)
content = "".join([part.text for part in response_parts if part.text])
tool_calls = []
for part in response_parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
if tool_calls:
chunk["tool_calls"] = tool_calls
chunk["content"] = content
yield chunk
except (
APIError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
if isinstance(err, APIError):
message = err.message
else:
message = type(err).__name__
error = f"{ERROR_GETTING_RESPONSE}: {message}"
raise HomeAssistantError(error) from err
class GoogleGenerativeAIConversationEntity( class GoogleGenerativeAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent conversation.ConversationEntity,
conversation.AbstractConversationAgent,
GoogleGenerativeAILLMBaseEntity,
): ):
"""Google Generative AI conversation agent.""" """Google Generative AI conversation agent."""
_attr_has_entity_name = True
_attr_name = None
_attr_supports_streaming = True _attr_supports_streaming = True
def __init__(self, entry: ConfigEntry) -> None: def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent.""" """Initialize the agent."""
self.entry = entry super().__init__(entry)
self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.entry.options.get(CONF_LLM_HASS_API): if self.entry.options.get(CONF_LLM_HASS_API):
self._attr_supported_features = ( self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL conversation.ConversationEntityFeature.CONTROL
@@ -358,13 +64,6 @@ class GoogleGenerativeAIConversationEntity(
conversation.async_unset_agent(self.hass, self.entry) conversation.async_unset_agent(self.hass, self.entry)
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()
def _fix_tool_name(self, tool_name: str) -> str:
"""Fix tool name if needed."""
# The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
# name. This makes sure when it incorrectly changes the name, that we change it
# back for HA to call.
return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
async def _async_handle_message( async def _async_handle_message(
self, self,
user_input: conversation.ConversationInput, user_input: conversation.ConversationInput,
@@ -374,11 +73,11 @@ class GoogleGenerativeAIConversationEntity(
options = self.entry.options options = self.entry.options
try: try:
await chat_log.async_update_llm_data( await chat_log.async_provide_llm_data(
DOMAIN, user_input.as_llm_context(DOMAIN),
user_input,
options.get(CONF_LLM_HASS_API), options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT), options.get(CONF_PROMPT),
user_input.extra_system_prompt,
) )
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()
@@ -399,163 +98,6 @@ class GoogleGenerativeAIConversationEntity(
continue_conversation=chat_log.continue_conversation, continue_conversation=chat_log.continue_conversation,
) )
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.entry.options
tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
# Using search grounding allows the model to retrieve information from the web,
# however, it may interfere with how the model decides to use some tools, or entities
# for example weather entity may be disregarded if the model chooses to Google it.
if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True:
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
and "gemini-2.0-flash-preview-image-generation" not in model_name
)
prompt_content = cast(
conversation.SystemContent,
chat_log.content[0],
)
if prompt_content.content:
prompt = prompt_content.content
else:
raise HomeAssistantError("Invalid prompt content")
messages: list[Content] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:-1]:
if chat_content.role == "tool_result":
tool_results.append(chat_content)
continue
if (
not isinstance(chat_content, conversation.ToolResultContent)
and chat_content.content == ""
):
# Skipping is not possible since the number of function calls need to match the number of function responses
# and skipping one would mean removing the other and hence this would prevent a proper chat log
chat_content = replace(chat_content, content=" ")
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(_convert_content(chat_content))
# The SDK requires the first message to be a user message
# This is not the case if user used `start_conversation`
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
if messages and messages[0].role != "user":
messages.insert(
0,
Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
messages = [
Content(role="user", parts=[Part.from_text(text=prompt)]),
Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
chat_request: str | list[Part] = user_message.content
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request
)
except (
APIError,
ClientError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
error = ERROR_GETTING_RESPONSE
raise HomeAssistantError(error) from err
chat_request = _create_google_tool_response_parts(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_response_generator),
)
if isinstance(content, conversation.ToolResultContent)
]
)
if not chat_log.unresponded_tool_results:
break
async def _async_entry_update_listener( async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry self, hass: HomeAssistant, entry: ConfigEntry
) -> None: ) -> None:

View File

@@ -0,0 +1,475 @@
"""Conversation support for the Google Generative AI Conversation integration."""
from __future__ import annotations
import codecs
from collections.abc import AsyncGenerator, Callable
from dataclasses import replace
from typing import Any, cast
from google.genai.errors import APIError, ClientError
from google.genai.types import (
AutomaticFunctionCallingConfig,
Content,
FunctionDeclaration,
GenerateContentConfig,
GenerateContentResponse,
GoogleSearch,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from .const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
ERROR_GETTING_RESPONSE = (
"Sorry, I had a problem getting a response from Google Generative AI."
)
SUPPORTED_SCHEMA_KEYS = {
# Gemini API does not support all of the OpenAPI schema
# SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
"max_items",
"min_items",
"properties",
"required",
"items",
}
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
def _format_schema(schema: dict[str, Any]) -> Schema:
"""Format the schema to be compatible with Gemini API."""
if subschemas := schema.get("allOf"):
for subschema in subschemas: # Gemini API does not support allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
subschemas[0]
) # Or, if not found, to any of the subschemas
result = {}
for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "type":
val = val.upper()
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type": "STRING"}}
result["required"] = []
return cast(Schema, result)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> Tool:
"""Format tool specification."""
if tool.parameters.schema:
parameters = _format_schema(
convert(tool.parameters, custom_serializer=custom_serializer)
)
else:
parameters = None
return Tool(
function_declarations=[
FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters=parameters,
)
]
)
def _escape_decode(value: Any) -> Any:
"""Recursively call codecs.escape_decode on all values."""
if isinstance(value, str):
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
return {k: _escape_decode(v) for k, v in value.items()}
return value
def _create_google_tool_response_parts(
parts: list[conversation.ToolResultContent],
) -> list[Part]:
"""Create Google tool response parts."""
return [
Part.from_function_response(
name=tool_result.tool_name, response=tool_result.tool_result
)
for tool_result in parts
]
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> Content:
"""Create a Google tool response content."""
return Content(
role="user",
parts=_create_google_tool_response_parts(content),
)
def _convert_content(
content: (
conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent
),
) -> Content:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
if content.content:
parts.append(Part.from_text(text=content.content))
if content.tool_calls:
parts.extend(
[
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
return Content(role="model", parts=parts)
async def _transform_stream(
result: AsyncGenerator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
chunk["role"] = "assistant"
new_message = False
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
reason = (
response.prompt_feedback.block_reason_message
if response.prompt_feedback
else "unknown"
)
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {reason}"
)
candidate = response.candidates[0]
if (
candidate.finish_reason is not None
and candidate.finish_reason != "STOP"
):
# The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason
LOGGER.error(
"Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason",
candidate.finish_reason,
)
raise HomeAssistantError(
f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}"
)
response_parts = (
candidate.content.parts
if candidate.content is not None and candidate.content.parts is not None
else []
)
content = "".join([part.text for part in response_parts if part.text])
tool_calls = []
for part in response_parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
if tool_calls:
chunk["tool_calls"] = tool_calls
chunk["content"] = content
yield chunk
except (
APIError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
if isinstance(err, APIError):
message = err.message
else:
message = type(err).__name__
error = f"{ERROR_GETTING_RESPONSE}: {message}"
raise HomeAssistantError(error) from err
class GoogleGenerativeAILLMBaseEntity(Entity):
"""Google Generative AI base entity."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.entry.options
tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
# Using search grounding allows the model to retrieve information from the web,
# however, it may interfere with how the model decides to use some tools, or entities
# for example weather entity may be disregarded if the model chooses to Google it.
if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True:
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
and "gemini-2.0-flash-preview-image-generation" not in model_name
)
prompt_content = cast(
conversation.SystemContent,
chat_log.content[0],
)
if prompt_content.content:
prompt = prompt_content.content
else:
raise HomeAssistantError("Invalid prompt content")
messages: list[Content] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:-1]:
if chat_content.role == "tool_result":
tool_results.append(chat_content)
continue
if (
not isinstance(chat_content, conversation.ToolResultContent)
and chat_content.content == ""
):
# Skipping is not possible since the number of function calls need to match the number of function responses
# and skipping one would mean removing the other and hence this would prevent a proper chat log
chat_content = replace(chat_content, content=" ")
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(_convert_content(chat_content))
# The SDK requires the first message to be a user message
# This is not the case if user used `start_conversation`
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
if messages and messages[0].role != "user":
messages.insert(
0,
Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
messages = [
Content(role="user", parts=[Part.from_text(text=prompt)]),
Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
chat_request: str | list[Part] = user_message.content
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request
)
except (
APIError,
ClientError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
error = ERROR_GETTING_RESPONSE
raise HomeAssistantError(error) from err
chat_request = _create_google_tool_response_parts(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_response_generator),
)
if isinstance(content, conversation.ToolResultContent)
]
)
if not chat_log.unresponded_tool_results:
break

View File

@@ -0,0 +1,216 @@
"""Text to speech support for Google Generative AI."""
from __future__ import annotations
from contextlib import suppress
import io
import logging
from typing import Any
import wave
from google.genai import types
from homeassistant.components.tts import (
ATTR_VOICE,
TextToSpeechEntity,
TtsAudioType,
Voice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TTS entity."""
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
async_add_entities([tts_entity])
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
"""Google Generative AI text-to-speech entity."""
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
_attr_supported_languages = [
"ar-EG",
"bn-BD",
"de-DE",
"en-IN",
"en-US",
"es-US",
"fr-FR",
"hi-IN",
"id-ID",
"it-IT",
"ja-JP",
"ko-KR",
"mr-IN",
"nl-NL",
"pl-PL",
"pt-BR",
"ro-RO",
"ru-RU",
"ta-IN",
"te-IN",
"th-TH",
"tr-TR",
"uk-UA",
"vi-VN",
]
_attr_default_language = "en-US"
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
_supported_voices = [
Voice(voice.split(" ", 1)[0].lower(), voice)
for voice in (
"Zephyr (Bright)",
"Puck (Upbeat)",
"Charon (Informative)",
"Kore (Firm)",
"Fenrir (Excitable)",
"Leda (Youthful)",
"Orus (Firm)",
"Aoede (Breezy)",
"Callirrhoe (Easy-going)",
"Autonoe (Bright)",
"Enceladus (Breathy)",
"Iapetus (Clear)",
"Umbriel (Easy-going)",
"Algieba (Smooth)",
"Despina (Smooth)",
"Erinome (Clear)",
"Algenib (Gravelly)",
"Rasalgethi (Informative)",
"Laomedeia (Upbeat)",
"Achernar (Soft)",
"Alnilam (Firm)",
"Schedar (Even)",
"Gacrux (Mature)",
"Pulcherrima (Forward)",
"Achird (Friendly)",
"Zubenelgenubi (Casual)",
"Vindemiatrix (Gentle)",
"Sadachbia (Lively)",
"Sadaltager (Knowledgeable)",
"Sulafat (Warm)",
)
]
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Google Generative AI Conversation speech entity."""
self.entry = entry
self._attr_name = "Google Generative AI TTS"
self._attr_unique_id = f"{entry.entry_id}_tts"
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._genai_client = entry.runtime_data
self._default_voice_id = self._supported_voices[0].voice_id
@callback
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
"""Return a list of supported voices for a language."""
return self._supported_voices
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
try:
response = self._genai_client.models.generate_content(
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options.get(
ATTR_VOICE, self._default_voice_id
)
)
)
),
),
)
data = response.candidates[0].content.parts[0].inline_data.data
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
except Exception as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
raise HomeAssistantError(exc) from exc
return "wav", self._convert_to_wav(data, mime_type)
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = self._parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}

View File

@@ -78,86 +78,85 @@ def _read_file_contents(
return results return results
async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
coordinator = config_entry.runtime_data
client_api = coordinator.client
upload_tasks = []
file_results = await call.hass.async_add_executor_job(
_read_file_contents, call.hass, call.data[CONF_FILENAME]
)
album = call.data[CONF_ALBUM]
try:
album_id = await coordinator.get_or_create_album(album)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="create_album_error",
translation_placeholders={"message": str(err)},
) from err
for mime_type, content in file_results:
upload_tasks.append(client_api.upload_content(content, mime_type))
try:
upload_results = await asyncio.gather(*upload_tasks)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="upload_error",
translation_placeholders={"message": str(err)},
) from err
try:
upload_result = await client_api.create_media_items(
[
NewMediaItem(SimpleMediaItem(upload_token=upload_result.upload_token))
for upload_result in upload_results
],
album_id=album_id,
)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": str(err)},
) from err
if call.return_response:
return {
"media_items": [
{"media_item_id": item_result.media_item.id}
for item_result in upload_result.new_media_item_results
if item_result.media_item and item_result.media_item.id
],
"album_id": album_id,
}
return None
@callback @callback
def async_setup_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None:
"""Register Google Photos services.""" """Register Google Photos services."""
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
coordinator = config_entry.runtime_data
client_api = coordinator.client
upload_tasks = []
file_results = await hass.async_add_executor_job(
_read_file_contents, hass, call.data[CONF_FILENAME]
)
album = call.data[CONF_ALBUM]
try:
album_id = await coordinator.get_or_create_album(album)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="create_album_error",
translation_placeholders={"message": str(err)},
) from err
for mime_type, content in file_results:
upload_tasks.append(client_api.upload_content(content, mime_type))
try:
upload_results = await asyncio.gather(*upload_tasks)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="upload_error",
translation_placeholders={"message": str(err)},
) from err
try:
upload_result = await client_api.create_media_items(
[
NewMediaItem(
SimpleMediaItem(upload_token=upload_result.upload_token)
)
for upload_result in upload_results
],
album_id=album_id,
)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": str(err)},
) from err
if call.return_response:
return {
"media_items": [
{"media_item_id": item_result.media_item.id}
for item_result in upload_result.new_media_item_results
if item_result.media_item and item_result.media_item.id
],
"album_id": album_id,
}
return None
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
UPLOAD_SERVICE, UPLOAD_SERVICE,
async_handle_upload, _async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA, schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL, supports_response=SupportsResponse.OPTIONAL,
) )

File diff suppressed because it is too large Load Diff

View File

@@ -133,8 +133,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData: def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData:
"""Parse the routing response dict to a HERETravelTimeData.""" """Parse the routing response dict to a HERETravelTimeData."""
distance: float = 0.0 distance: float = 0.0
duration: float = 0.0 duration: int = 0
duration_in_traffic: float = 0.0 duration_in_traffic: int = 0
for section in response["routes"][0]["sections"]: for section in response["routes"][0]["sections"]:
distance += DistanceConverter.convert( distance += DistanceConverter.convert(
@@ -167,8 +167,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
destination_name = names[0]["value"] destination_name = names[0]["value"]
return HERETravelTimeData( return HERETravelTimeData(
attribution=None, attribution=None,
duration=round(duration / 60), duration=duration,
duration_in_traffic=round(duration_in_traffic / 60), duration_in_traffic=duration_in_traffic,
distance=distance, distance=distance,
origin=f"{mapped_origin_lat},{mapped_origin_lon}", origin=f"{mapped_origin_lat},{mapped_origin_lon}",
destination=f"{mapped_destination_lat},{mapped_destination_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}",
@@ -271,13 +271,13 @@ class HERETransitDataUpdateCoordinator(
UnitOfLength.METERS, UnitOfLength.METERS,
UnitOfLength.KILOMETERS, UnitOfLength.KILOMETERS,
) )
duration: float = sum( duration: int = sum(
section["travelSummary"]["duration"] for section in sections section["travelSummary"]["duration"] for section in sections
) )
return HERETravelTimeData( return HERETravelTimeData(
attribution=attribution, attribution=attribution,
duration=round(duration / 60), duration=duration,
duration_in_traffic=round(duration / 60), duration_in_traffic=duration,
distance=distance, distance=distance,
origin=f"{mapped_origin_lat},{mapped_origin_lon}", origin=f"{mapped_origin_lat},{mapped_origin_lon}",
destination=f"{mapped_destination_lat},{mapped_destination_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}",

View File

@@ -55,14 +55,18 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]
icon=ICONS.get(travel_mode, ICON_CAR), icon=ICONS.get(travel_mode, ICON_CAR),
key=ATTR_DURATION, key=ATTR_DURATION,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
), ),
SensorEntityDescription( SensorEntityDescription(
translation_key="duration_in_traffic", translation_key="duration_in_traffic",
icon=ICONS.get(travel_mode, ICON_CAR), icon=ICONS.get(travel_mode, ICON_CAR),
key=ATTR_DURATION_IN_TRAFFIC, key=ATTR_DURATION_IN_TRAFFIC,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
), ),
SensorEntityDescription( SensorEntityDescription(
translation_key="distance", translation_key="distance",

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.74", "babel==2.15.0"] "requirements": ["holidays==0.75", "babel==2.15.0"]
} }

View File

@@ -21,6 +21,7 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect", "documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiohomeconnect"], "loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.17.1"], "quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.18.0"],
"zeroconf": ["_homeconnect._tcp.local."] "zeroconf": ["_homeconnect._tcp.local."]
} }

View File

@@ -0,0 +1,71 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: done
comment: |
Full polling is performed at the configuration entry setup and
device polling is performed when a CONNECTED or a PAIRED event is received.
If many CONNECTED or PAIRED events are received for a device within a short time span,
the integration will stop polling for that device and will create a repair issue.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: done
comment: |
Event entities are disabled by default to prevent user confusion regarding
which events are supported by its appliance.
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: |
This integration doesn't have settings in its configuration flow.
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -1,9 +1,13 @@
"""The homee event platform.""" """The homee event platform."""
from pyHomee.const import AttributeType from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute from pyHomee.model import HomeeAttribute
from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -13,6 +17,38 @@ from .entity import HomeeEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
REMOTE_PROFILES = [
NodeProfile.REMOTE,
NodeProfile.TWO_BUTTON_REMOTE,
NodeProfile.THREE_BUTTON_REMOTE,
NodeProfile.FOUR_BUTTON_REMOTE,
]
EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = {
AttributeType.BUTTON_STATE: EventEntityDescription(
key="button_state",
device_class=EventDeviceClass.BUTTON,
event_types=["upper", "lower", "released"],
),
AttributeType.UP_DOWN_REMOTE: EventEntityDescription(
key="up_down_remote",
device_class=EventDeviceClass.BUTTON,
event_types=[
"released",
"up",
"down",
"stop",
"up_long",
"down_long",
"stop_long",
"c_button",
"b_button",
"a_button",
],
),
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: HomeeConfigEntry, config_entry: HomeeConfigEntry,
@@ -21,30 +57,31 @@ async def async_setup_entry(
"""Add event entities for homee.""" """Add event entities for homee."""
async_add_entities( async_add_entities(
HomeeEvent(attribute, config_entry) HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes for node in config_entry.runtime_data.nodes
for attribute in node.attributes for attribute in node.attributes
if attribute.type == AttributeType.UP_DOWN_REMOTE if attribute.type in EVENT_DESCRIPTIONS
and node.profile in REMOTE_PROFILES
and not attribute.editable
) )
class HomeeEvent(HomeeEntity, EventEntity): class HomeeEvent(HomeeEntity, EventEntity):
"""Representation of a homee event.""" """Representation of a homee event."""
_attr_translation_key = "up_down_remote" def __init__(
_attr_event_types = [ self,
"released", attribute: HomeeAttribute,
"up", entry: HomeeConfigEntry,
"down", description: EventEntityDescription,
"stop", ) -> None:
"up_long", """Initialize the homee event entity."""
"down_long", super().__init__(attribute, entry)
"stop_long", self.entity_description = description
"c_button", self._attr_translation_key = description.key
"b_button", if attribute.instance > 0:
"a_button", self._attr_translation_key = f"{self._attr_translation_key}_instance"
] self._attr_translation_placeholders = {"instance": str(attribute.instance)}
_attr_device_class = EventDeviceClass.BUTTON
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Add the homee event entity to home assistant.""" """Add the homee event entity to home assistant."""
@@ -56,6 +93,5 @@ class HomeeEvent(HomeeEntity, EventEntity):
@callback @callback
def _event_triggered(self, event: HomeeAttribute) -> None: def _event_triggered(self, event: HomeeAttribute) -> None:
"""Handle a homee event.""" """Handle a homee event."""
if event.type == AttributeType.UP_DOWN_REMOTE: self._trigger_event(self.event_types[int(event.current_value)])
self._trigger_event(self.event_types[int(event.current_value)]) self.schedule_update_ha_state()
self.schedule_update_ha_state()

View File

@@ -160,12 +160,36 @@
} }
}, },
"event": { "event": {
"button_state": {
"name": "Switch",
"state_attributes": {
"event_type": {
"state": {
"upper": "Upper button",
"lower": "Lower button",
"released": "Released"
}
}
}
},
"button_state_instance": {
"name": "Switch {instance}",
"state_attributes": {
"event_type": {
"state": {
"upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]",
"lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]",
"released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]"
}
}
}
},
"up_down_remote": { "up_down_remote": {
"name": "Up/down remote", "name": "Up/down remote",
"state_attributes": { "state_attributes": {
"event_type": { "event_type": {
"state": { "state": {
"release": "Released", "release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]",
"up": "Up", "up": "Up",
"down": "Down", "down": "Down",
"stop": "Stop", "stop": "Stop",

View File

@@ -128,6 +128,7 @@ class HomematicipHAP:
self.config_entry.data.get(HMIPC_AUTHTOKEN), self.config_entry.data.get(HMIPC_AUTHTOKEN),
self.config_entry.data.get(HMIPC_NAME), self.config_entry.data.get(HMIPC_NAME),
) )
except HmipcConnectionError as err: except HmipcConnectionError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
@@ -210,41 +211,13 @@ class HomematicipHAP:
for device in self.home.devices: for device in self.home.devices:
device.fire_update_event() device.fire_update_event()
async def async_connect(self) -> None: async def async_connect(self, home: AsyncHome) -> None:
"""Start WebSocket connection.""" """Connect to HomematicIP Cloud Websocket."""
tries = 0 await home.enable_events()
while True:
retry_delay = 2 ** min(tries, 8)
try: home.set_on_connected_handler(self.ws_connected_handler)
await self.home.get_current_state_async() home.set_on_disconnected_handler(self.ws_disconnected_handler)
hmip_events = self.home.enable_events() home.set_on_reconnect_handler(self.ws_reconnected_handler)
self.home.set_on_connected_handler(self.ws_connected_handler)
self.home.set_on_disconnected_handler(self.ws_disconnected_handler)
tries = 0
await hmip_events
except HmipConnectionError:
_LOGGER.error(
(
"Error connecting to HomematicIP with HAP %s. "
"Retrying in %d seconds"
),
self.config_entry.unique_id,
retry_delay,
)
if self._ws_close_requested:
break
self._ws_close_requested = False
tries += 1
try:
self._retry_task = self.hass.async_create_task(
asyncio.sleep(retry_delay)
)
await self._retry_task
except asyncio.CancelledError:
break
async def async_reset(self) -> bool: async def async_reset(self) -> bool:
"""Close the websocket connection.""" """Close the websocket connection."""
@@ -272,14 +245,22 @@ class HomematicipHAP:
async def ws_connected_handler(self) -> None: async def ws_connected_handler(self) -> None:
"""Handle websocket connected.""" """Handle websocket connected."""
_LOGGER.debug("WebSocket connection to HomematicIP established") _LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set(): if self._ws_connection_closed.is_set():
await self.get_state() await self.get_state()
self._ws_connection_closed.clear() self._ws_connection_closed.clear()
async def ws_disconnected_handler(self) -> None: async def ws_disconnected_handler(self) -> None:
"""Handle websocket disconnection.""" """Handle websocket disconnection."""
_LOGGER.warning("WebSocket connection to HomematicIP closed") _LOGGER.warning("Websocket connection to HomematicIP Cloud closed")
self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection."""
_LOGGER.info(
"Websocket connection to HomematicIP Cloud re-established due to reason: %s",
reason,
)
self._ws_connection_closed.set() self._ws_connection_closed.set()
async def get_hap( async def get_hap(
@@ -306,6 +287,6 @@ class HomematicipHAP:
home.on_update(self.async_update) home.on_update(self.async_update)
home.on_create(self.async_create_entity) home.on_create(self.async_create_entity)
hass.loop.create_task(self.async_connect()) await self.async_connect(home)
return home return home

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["homematicip"], "loggers": ["homematicip"],
"requirements": ["homematicip==2.0.4"] "requirements": ["homematicip==2.0.5"]
} }

View File

@@ -4,13 +4,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from homematicip.base.enums import DeviceType from homematicip.base.enums import DeviceType, FunctionalChannelType
from homematicip.device import ( from homematicip.device import (
BrandSwitch2, BrandSwitch2,
DinRailSwitch, DinRailSwitch,
DinRailSwitch4, DinRailSwitch4,
FullFlushInputSwitch, FullFlushInputSwitch,
HeatingSwitch2, HeatingSwitch2,
MotionDetectorSwitchOutdoor,
MultiIOBox, MultiIOBox,
OpenCollector8Module, OpenCollector8Module,
PlugableSwitch, PlugableSwitch,
@@ -47,18 +48,34 @@ async def async_setup_entry(
and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING
): ):
entities.append(HomematicipSwitchMeasuring(hap, device)) entities.append(HomematicipSwitchMeasuring(hap, device))
elif isinstance(device, WiredSwitch8): elif isinstance(
device,
(
WiredSwitch8,
OpenCollector8Module,
BrandSwitch2,
PrintedCircuitBoardSwitch2,
HeatingSwitch2,
MultiIOBox,
MotionDetectorSwitchOutdoor,
DinRailSwitch,
DinRailSwitch4,
),
):
channel_indices = [
ch.index
for ch in device.functionalChannels
if ch.functionalChannelType
in (
FunctionalChannelType.SWITCH_CHANNEL,
FunctionalChannelType.MULTI_MODE_INPUT_SWITCH_CHANNEL,
)
]
entities.extend( entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel) HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9) for channel in channel_indices
)
elif isinstance(device, DinRailSwitch):
entities.append(HomematicipMultiSwitch(hap, device, channel=1))
elif isinstance(device, DinRailSwitch4):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 5)
) )
elif isinstance( elif isinstance(
device, device,
( (
@@ -68,24 +85,6 @@ async def async_setup_entry(
), ),
): ):
entities.append(HomematicipSwitch(hap, device)) entities.append(HomematicipSwitch(hap, device))
elif isinstance(device, OpenCollector8Module):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9)
)
elif isinstance(
device,
(
BrandSwitch2,
PrintedCircuitBoardSwitch2,
HeatingSwitch2,
MultiIOBox,
),
):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 3)
)
async_add_entities(entities) async_add_entities(entities)
@@ -108,15 +107,15 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if switch is on.""" """Return true if switch is on."""
return self._device.functionalChannels[self._channel].on return self.functional_channel.on
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on.""" """Turn the switch on."""
await self._device.turn_on_async(self._channel) await self.functional_channel.async_turn_on()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off.""" """Turn the switch off."""
await self._device.turn_off_async(self._channel) await self.functional_channel.async_turn_off()
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):

View File

@@ -23,9 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
api: HomeWizardEnergy api: HomeWizardEnergy
is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False if token := entry.data.get(CONF_TOKEN):
if (token := entry.data.get(CONF_TOKEN)) and is_battery:
api = HomeWizardEnergyV2( api = HomeWizardEnergyV2(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_IP_ADDRESS],
token=token, token=token,
@@ -37,8 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
clientsession=async_get_clientsession(hass), clientsession=async_get_clientsession(hass),
) )
if is_battery: await async_check_v2_support_and_create_issue(hass, entry)
await async_check_v2_support_and_create_issue(hass, entry)
coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api) coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api)
try: try:

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aioautomower==2025.5.1"] "requirements": ["aioautomower==2025.6.0"]
} }

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.8"] "requirements": ["pylamarzocco==2.0.9"]
} }

View File

@@ -58,6 +58,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
).target_temperature ).target_temperature
), ),
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
), ),
LaMarzoccoNumberEntityDescription( LaMarzoccoNumberEntityDescription(
key="smart_standby_time", key="smart_standby_time",
@@ -221,7 +225,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
entity_description: LaMarzoccoNumberEntityDescription entity_description: LaMarzoccoNumberEntityDescription
@property @property
def native_value(self) -> float: def native_value(self) -> float | int:
"""Return the current value.""" """Return the current value."""
return self.entity_description.native_value_fn(self.coordinator.device) return self.entity_description.native_value_fn(self.coordinator.device)

View File

@@ -57,6 +57,10 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
).ready_start_time ).ready_start_time
), ),
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
), ),
LaMarzoccoSensorEntityDescription( LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time", key="steam_boiler_ready_time",

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pypck"], "loggers": ["pypck"],
"requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] "requirements": ["pypck==0.8.8", "lcn-frontend==0.2.5"]
} }

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta
import logging import logging
from letpot.deviceclient import LetPotDeviceClient from letpot.deviceclient import LetPotDeviceClient
@@ -42,6 +43,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=f"LetPot {device.serial_number}", name=f"LetPot {device.serial_number}",
update_interval=timedelta(minutes=10),
) )
self._info = info self._info = info
self.device = device self.device = device

View File

@@ -5,9 +5,9 @@ rules:
comment: | comment: |
This integration does not provide additional actions. This integration does not provide additional actions.
appropriate-polling: appropriate-polling:
status: exempt status: done
comment: | comment: |
This integration only receives push-based updates. Primarily uses push, but polls with a long interval for availability and missed updates.
brands: done brands: done
common-modules: done common-modules: done
config-flow-test-coverage: done config-flow-test-coverage: done
@@ -39,7 +39,7 @@ rules:
comment: | comment: |
The integration does not have configuration options. The integration does not have configuration options.
docs-installation-parameters: done docs-installation-parameters: done
entity-unavailable: todo entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: todo log-when-unavailable: todo
parallel-updates: done parallel-updates: done

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylitterbot"], "loggers": ["pylitterbot"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pylitterbot==2024.0.0"] "requirements": ["pylitterbot==2024.2.0"]
} }

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar", "documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ical"], "loggers": ["ical"],
"requirements": ["ical==10.0.0"] "requirements": ["ical==10.0.4"]
} }

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo", "documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["ical==10.0.0"] "requirements": ["ical==10.0.4"]
} }

View File

@@ -88,7 +88,6 @@ class ModelContextProtocolSSEView(HomeAssistantView):
context = llm.LLMContext( context = llm.LLMContext(
platform=DOMAIN, platform=DOMAIN,
context=self.context(request), context=self.context(request),
user_prompt=None,
language="*", language="*",
assistant=conversation.DOMAIN, assistant=conversation.DOMAIN,
device_id=None, device_id=None,

View File

@@ -1,93 +1,28 @@
"""The Meater Temperature Probe integration.""" """The Meater Temperature Probe integration."""
import asyncio from homeassistant.const import Platform
from datetime import timedelta
import logging
from meater import (
AuthenticationError,
MeaterApi,
ServiceUnavailableError,
TooManyRequestsError,
)
from meater.MeaterApi import MeaterProbe
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
from .coordinator import MeaterConfigEntry, MeaterCoordinator
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Meater Temperature Probe from a config entry.""" """Set up Meater Temperature Probe from a config entry."""
# Store an API object to access
session = async_get_clientsession(hass)
meater_api = MeaterApi(session)
# Add the credentials coordinator = MeaterCoordinator(hass, entry)
try:
_LOGGER.debug("Authenticating with the Meater API")
await meater_api.authenticate(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]
)
except (ServiceUnavailableError, TooManyRequestsError) as err:
raise ConfigEntryNotReady from err
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
f"Unable to authenticate with the Meater API: {err}"
) from err
async def async_update_data() -> dict[str, MeaterProbe]:
"""Fetch data from API endpoint."""
try:
# Note: TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with asyncio.timeout(10):
devices: list[MeaterProbe] = await meater_api.get_all_devices()
except AuthenticationError as err:
raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err
except TooManyRequestsError as err:
raise UpdateFailed(
"Too many requests have been made to the API, rate limiting is in place"
) from err
return {device.id: device for device in devices}
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
# Name of the data. For logging purposes.
name="meater_api",
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(seconds=30),
)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set())
hass.data[DOMAIN].setdefault("known_probes", set())
hass.data[DOMAIN][entry.entry_id] = { entry.runtime_data = coordinator
"api": meater_api,
"coordinator": coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,77 @@
"""Meater Coordinator."""
import asyncio
from datetime import timedelta
import logging
from meater.MeaterApi import (
AuthenticationError,
MeaterApi,
MeaterProbe,
ServiceUnavailableError,
TooManyRequestsError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
type MeaterConfigEntry = ConfigEntry[MeaterCoordinator]
class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
"""Meater Coordinator."""
config_entry: MeaterConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MeaterConfigEntry,
) -> None:
"""Initialize the Meater Coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"Meater {entry.title}",
update_interval=timedelta(seconds=30),
)
session = async_get_clientsession(hass)
self.client = MeaterApi(session)
async def _async_setup(self) -> None:
"""Set up the Meater Coordinator."""
try:
_LOGGER.debug("Authenticating with the Meater API")
await self.client.authenticate(
self.config_entry.data[CONF_USERNAME],
self.config_entry.data[CONF_PASSWORD],
)
except (ServiceUnavailableError, TooManyRequestsError) as err:
raise UpdateFailed from err
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
f"Unable to authenticate with the Meater API: {err}"
) from err
async def _async_update_data(self) -> dict[str, MeaterProbe]:
"""Fetch data from API endpoint."""
try:
# Note: TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with asyncio.timeout(10):
devices: list[MeaterProbe] = await self.client.get_all_devices()
except AuthenticationError as err:
raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err
except TooManyRequestsError as err:
raise UpdateFailed(
"Too many requests have been made to the API, rate limiting is in place"
) from err
return {device.id: device for device in devices}

View File

@@ -0,0 +1,55 @@
"""Diagnostics support for the Meater integration."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from . import MeaterConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: MeaterConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
return {
identifier: {
"id": probe.id,
"internal_temperature": probe.internal_temperature,
"ambient_temperature": probe.ambient_temperature,
"time_updated": probe.time_updated.isoformat(),
"cook": (
{
"id": probe.cook.id,
"name": probe.cook.name,
"state": probe.cook.state,
"target_temperature": (
probe.cook.target_temperature
if hasattr(probe.cook, "target_temperature")
else None
),
"peak_temperature": (
probe.cook.peak_temperature
if hasattr(probe.cook, "peak_temperature")
else None
),
"time_remaining": (
probe.cook.time_remaining
if hasattr(probe.cook, "time_remaining")
else None
),
"time_elapsed": (
probe.cook.time_elapsed
if hasattr(probe.cook, "time_elapsed")
else None
),
}
if probe.cook
else None
),
}
for identifier, probe in coordinator.data.items()
}

View File

@@ -14,18 +14,28 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import MeaterCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import MeaterConfigEntry
COOK_STATES = {
"Not Started": "not_started",
"Configured": "configured",
"Started": "started",
"Ready For Resting": "ready_for_resting",
"Resting": "resting",
"Slightly Underdone": "slightly_underdone",
"Finished": "finished",
"Slightly Overdone": "slightly_overdone",
"OVERCOOK!": "overcooked",
}
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -82,13 +92,13 @@ SENSOR_TYPES = (
available=lambda probe: probe is not None and probe.cook is not None, available=lambda probe: probe is not None and probe.cook is not None,
value=lambda probe: probe.cook.name if probe.cook else None, value=lambda probe: probe.cook.name if probe.cook else None,
), ),
# One of Not Started, Configured, Started, Ready For Resting, Resting,
# Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated.
MeaterSensorEntityDescription( MeaterSensorEntityDescription(
key="cook_state", key="cook_state",
translation_key="cook_state", translation_key="cook_state",
available=lambda probe: probe is not None and probe.cook is not None, available=lambda probe: probe is not None and probe.cook is not None,
value=lambda probe: probe.cook.state if probe.cook else None, device_class=SensorDeviceClass.ENUM,
options=list(COOK_STATES.values()),
value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None,
), ),
# Target temperature # Target temperature
MeaterSensorEntityDescription( MeaterSensorEntityDescription(
@@ -137,13 +147,11 @@ SENSOR_TYPES = (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: MeaterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the entry.""" """Set up the entry."""
coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][ coordinator = entry.runtime_data
entry.entry_id
]["coordinator"]
@callback @callback
def async_update_data(): def async_update_data():
@@ -174,11 +182,10 @@ async def async_setup_entry(
# Add a subscriber to the coordinator to discover new temperature probes # Add a subscriber to the coordinator to discover new temperature probes
coordinator.async_add_listener(async_update_data) coordinator.async_add_listener(async_update_data)
async_update_data()
class MeaterProbeTemperature( class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]):
SensorEntity, CoordinatorEntity[DataUpdateCoordinator[dict[str, MeaterProbe]]]
):
"""Meater Temperature Sensor Entity.""" """Meater Temperature Sensor Entity."""
entity_description: MeaterSensorEntityDescription entity_description: MeaterSensorEntityDescription

View File

@@ -40,7 +40,18 @@
"name": "Cooking" "name": "Cooking"
}, },
"cook_state": { "cook_state": {
"name": "Cook state" "name": "Cook state",
"state": {
"not_started": "Not started",
"configured": "Configured",
"started": "Started",
"ready_for_resting": "Ready for resting",
"resting": "Resting",
"slightly_underdone": "Slightly underdone",
"finished": "Finished",
"slightly_overdone": "Slightly overdone",
"overcooked": "Overcooked"
}
}, },
"cook_target_temp": { "cook_target_temp": {
"name": "Target temperature" "name": "Target temperature"

View File

@@ -814,19 +814,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Flag media player features that are supported.""" """Flag media player features that are supported."""
return self._attr_supported_features return self._attr_supported_features
@property
def supported_features_compat(self) -> MediaPlayerEntityFeature:
"""Return the supported features as MediaPlayerEntityFeature.
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
if type(features) is int:
new_features = MediaPlayerEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
def turn_on(self) -> None: def turn_on(self) -> None:
"""Turn the media player on.""" """Turn the media player on."""
raise NotImplementedError raise NotImplementedError
@@ -966,87 +953,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property @property
def support_play(self) -> bool: def support_play(self) -> bool:
"""Boolean if play is supported.""" """Boolean if play is supported."""
return MediaPlayerEntityFeature.PLAY in self.supported_features_compat return MediaPlayerEntityFeature.PLAY in self.supported_features
@final @final
@property @property
def support_pause(self) -> bool: def support_pause(self) -> bool:
"""Boolean if pause is supported.""" """Boolean if pause is supported."""
return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat return MediaPlayerEntityFeature.PAUSE in self.supported_features
@final @final
@property @property
def support_stop(self) -> bool: def support_stop(self) -> bool:
"""Boolean if stop is supported.""" """Boolean if stop is supported."""
return MediaPlayerEntityFeature.STOP in self.supported_features_compat return MediaPlayerEntityFeature.STOP in self.supported_features
@final @final
@property @property
def support_seek(self) -> bool: def support_seek(self) -> bool:
"""Boolean if seek is supported.""" """Boolean if seek is supported."""
return MediaPlayerEntityFeature.SEEK in self.supported_features_compat return MediaPlayerEntityFeature.SEEK in self.supported_features
@final @final
@property @property
def support_volume_set(self) -> bool: def support_volume_set(self) -> bool:
"""Boolean if setting volume is supported.""" """Boolean if setting volume is supported."""
return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features
@final @final
@property @property
def support_volume_mute(self) -> bool: def support_volume_mute(self) -> bool:
"""Boolean if muting volume is supported.""" """Boolean if muting volume is supported."""
return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features
@final @final
@property @property
def support_previous_track(self) -> bool: def support_previous_track(self) -> bool:
"""Boolean if previous track command supported.""" """Boolean if previous track command supported."""
return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features
@final @final
@property @property
def support_next_track(self) -> bool: def support_next_track(self) -> bool:
"""Boolean if next track command supported.""" """Boolean if next track command supported."""
return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features
@final @final
@property @property
def support_play_media(self) -> bool: def support_play_media(self) -> bool:
"""Boolean if play media command supported.""" """Boolean if play media command supported."""
return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features
@final @final
@property @property
def support_select_source(self) -> bool: def support_select_source(self) -> bool:
"""Boolean if select source command supported.""" """Boolean if select source command supported."""
return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features
@final @final
@property @property
def support_select_sound_mode(self) -> bool: def support_select_sound_mode(self) -> bool:
"""Boolean if select sound mode command supported.""" """Boolean if select sound mode command supported."""
return ( return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features
MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat
)
@final @final
@property @property
def support_clear_playlist(self) -> bool: def support_clear_playlist(self) -> bool:
"""Boolean if clear playlist command supported.""" """Boolean if clear playlist command supported."""
return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features
@final @final
@property @property
def support_shuffle_set(self) -> bool: def support_shuffle_set(self) -> bool:
"""Boolean if shuffle is supported.""" """Boolean if shuffle is supported."""
return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features
@final @final
@property @property
def support_grouping(self) -> bool: def support_grouping(self) -> bool:
"""Boolean if player grouping is supported.""" """Boolean if player grouping is supported."""
return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat return MediaPlayerEntityFeature.GROUPING in self.supported_features
async def async_toggle(self) -> None: async def async_toggle(self) -> None:
"""Toggle the power on the media player.""" """Toggle the power on the media player."""
@@ -1074,7 +1059,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ( if (
self.volume_level is not None self.volume_level is not None
and self.volume_level < 1 and self.volume_level < 1
and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features
): ):
await self.async_set_volume_level( await self.async_set_volume_level(
min(1, self.volume_level + self.volume_step) min(1, self.volume_level + self.volume_step)
@@ -1092,7 +1077,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ( if (
self.volume_level is not None self.volume_level is not None
and self.volume_level > 0 and self.volume_level > 0
and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features
): ):
await self.async_set_volume_level( await self.async_set_volume_level(
max(0, self.volume_level - self.volume_step) max(0, self.volume_level - self.volume_step)
@@ -1135,7 +1120,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def capability_attributes(self) -> dict[str, Any]: def capability_attributes(self) -> dict[str, Any]:
"""Return capability attributes.""" """Return capability attributes."""
data: dict[str, Any] = {} data: dict[str, Any] = {}
supported_features = self.supported_features_compat supported_features = self.supported_features
if ( if (
source_list := self.source_list source_list := self.source_list
@@ -1364,7 +1349,7 @@ async def websocket_browse_media(
connection.send_error(msg["id"], "entity_not_found", "Entity not found") connection.send_error(msg["id"], "entity_not_found", "Entity not found")
return return
if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features:
connection.send_message( connection.send_message(
websocket_api.error_message( websocket_api.error_message(
msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media"
@@ -1447,7 +1432,7 @@ async def websocket_search_media(
connection.send_error(msg["id"], "entity_not_found", "Entity not found") connection.send_error(msg["id"], "entity_not_found", "Entity not found")
return return
if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features:
connection.send_message( connection.send_message(
websocket_api.error_message( websocket_api.error_message(
msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media"

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import dns.asyncresolver
import dns.rdata import dns.rdata
import dns.rdataclass import dns.rdataclass
import dns.rdatatype import dns.rdatatype
@@ -22,20 +23,23 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def load_dnspython_rdata_classes() -> None: def prevent_dnspython_blocking_operations() -> None:
"""Load dnspython rdata classes used by mcstatus.""" """Prevent dnspython blocking operations by pre-loading required data."""
# Blocking import: https://github.com/rthalley/dnspython/issues/1083
for rdtype in dns.rdatatype.RdataType: for rdtype in dns.rdatatype.RdataType:
if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT:
dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call]
# Blocking open: https://github.com/rthalley/dnspython/issues/1200
dns.asyncresolver.get_default_resolver()
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: MinecraftServerConfigEntry hass: HomeAssistant, entry: MinecraftServerConfigEntry
) -> bool: ) -> bool:
"""Set up Minecraft Server from a config entry.""" """Set up Minecraft Server from a config entry."""
await hass.async_add_executor_job(prevent_dnspython_blocking_operations)
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
await hass.async_add_executor_job(load_dnspython_rdata_classes)
# Create coordinator instance and store it. # Create coordinator instance and store it.
coordinator = MinecraftServerCoordinator(hass, entry) coordinator = MinecraftServerCoordinator(hass, entry)

View File

@@ -62,6 +62,7 @@ TILT_DEVICE_MAP = {
BlindType.VerticalBlind: CoverDeviceClass.BLIND, BlindType.VerticalBlind: CoverDeviceClass.BLIND,
BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND, BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND,
BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, BlindType.VerticalBlindRight: CoverDeviceClass.BLIND,
BlindType.RollerTiltMotor: CoverDeviceClass.BLIND,
} }
TILT_ONLY_DEVICE_MAP = { TILT_ONLY_DEVICE_MAP = {

View File

@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/motion_blinds", "documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["motionblinds"], "loggers": ["motionblinds"],
"requirements": ["motionblinds==0.6.27"] "requirements": ["motionblinds==0.6.28"]
} }

View File

@@ -35,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@@ -48,7 +47,6 @@ from .const import (
CONF_OPTIONS, CONF_OPTIONS,
CONF_STATE_TOPIC, CONF_STATE_TOPIC,
CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUGGESTED_DISPLAY_PRECISION,
DOMAIN,
PAYLOAD_NONE, PAYLOAD_NONE,
) )
from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
@@ -138,12 +136,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
device_class in DEVICE_CLASS_UNITS device_class in DEVICE_CLASS_UNITS
and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
): ):
_LOGGER.warning( raise vol.Invalid(
"The unit of measurement `%s` is not valid " f"The unit of measurement `{unit_of_measurement}` is not valid "
"together with device class `%s`. " f"together with device class `{device_class}`",
"this will stop working in HA Core 2025.7.0",
unit_of_measurement,
device_class,
) )
return config return config
@@ -194,40 +189,8 @@ class MqttSensor(MqttEntity, RestoreSensor):
None None
) )
@callback
def async_check_uom(self) -> None:
"""Check if the unit of measurement is valid with the device class."""
if (
self._discovery_data is not None
or self.device_class is None
or self.native_unit_of_measurement is None
):
return
if (
self.device_class in DEVICE_CLASS_UNITS
and self.native_unit_of_measurement
not in DEVICE_CLASS_UNITS[self.device_class]
):
async_create_issue(
self.hass,
DOMAIN,
self.entity_id,
issue_domain=sensor.DOMAIN,
is_fixable=False,
severity=IssueSeverity.WARNING,
learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM,
translation_placeholders={
"uom": self.native_unit_of_measurement,
"device_class": self.device_class.value,
"entity_id": self.entity_id,
},
translation_key="invalid_unit_of_measurement",
breaks_in_ha_version="2025.7.0",
)
async def mqtt_async_added_to_hass(self) -> None: async def mqtt_async_added_to_hass(self) -> None:
"""Restore state for entities with expire_after set.""" """Restore state for entities with expire_after set."""
self.async_check_uom()
last_state: State | None last_state: State | None
last_sensor_data: SensorExtraStoredData | None last_sensor_data: SensorExtraStoredData | None
if ( if (

View File

@@ -3,10 +3,6 @@
"invalid_platform_config": { "invalid_platform_config": {
"title": "Invalid config found for MQTT {domain} item", "title": "Invalid config found for MQTT {domain} item",
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
},
"invalid_unit_of_measurement": {
"title": "Sensor with invalid unit of measurement",
"description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
} }
}, },
"config": { "config": {

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mysensors", "documentation": "https://www.home-assistant.io/integrations/mysensors",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["mysensors"], "loggers": ["mysensors"],
"requirements": ["pymysensors==0.24.0"] "requirements": ["pymysensors==0.25.0"]
} }

View File

@@ -6,5 +6,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["nessclient"], "loggers": ["nessclient"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["nessclient==1.1.2"] "requirements": ["nessclient==1.2.0"]
} }

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nextbus", "documentation": "https://www.home-assistant.io/integrations/nextbus",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["py_nextbus"], "loggers": ["py_nextbus"],
"requirements": ["py-nextbusnext==2.2.0"] "requirements": ["py-nextbusnext==2.3.0"]
} }

View File

@@ -13,14 +13,13 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .coordinator import NextDnsUpdateCoordinator from .entity import NextDnsEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -61,30 +60,14 @@ async def async_setup_entry(
) )
class NextDnsBinarySensor( class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity):
CoordinatorEntity[NextDnsUpdateCoordinator[ConnectionStatus]], BinarySensorEntity
):
"""Define an NextDNS binary sensor.""" """Define an NextDNS binary sensor."""
_attr_has_entity_name = True
entity_description: NextDnsBinarySensorEntityDescription entity_description: NextDnsBinarySensorEntityDescription
def __init__( @property
self, def is_on(self) -> bool:
coordinator: NextDnsUpdateCoordinator[ConnectionStatus], """Return True if the binary sensor is on."""
description: NextDnsBinarySensorEntityDescription, return self.entity_description.state(
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self._attr_is_on = description.state(coordinator.data, coordinator.profile_id)
self.entity_description = description
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_is_on = self.entity_description.state(
self.coordinator.data, self.coordinator.profile_id self.coordinator.data, self.coordinator.profile_id
) )
self.async_write_ha_state()

View File

@@ -4,21 +4,21 @@ from __future__ import annotations
from aiohttp import ClientError from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError from nextdns import ApiError, InvalidApiKeyError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator from .entity import NextDnsEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
CLEAR_LOGS_BUTTON = ButtonEntityDescription( CLEAR_LOGS_BUTTON = ButtonEntityDescription(
key="clear_logs", key="clear_logs",
translation_key="clear_logs", translation_key="clear_logs",
@@ -37,24 +37,9 @@ async def async_setup_entry(
async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)])
class NextDnsButton( class NextDnsButton(NextDnsEntity, ButtonEntity):
CoordinatorEntity[NextDnsUpdateCoordinator[AnalyticsStatus]], ButtonEntity
):
"""Define an NextDNS button.""" """Define an NextDNS button."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: NextDnsUpdateCoordinator[AnalyticsStatus],
description: ButtonEntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self.entity_description = description
async def async_press(self) -> None: async def async_press(self) -> None:
"""Trigger cleaning logs.""" """Trigger cleaning logs."""
try: try:

View File

@@ -24,7 +24,6 @@ from tenacity import RetryError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -53,14 +52,6 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]):
"""Initialize.""" """Initialize."""
self.nextdns = nextdns self.nextdns = nextdns
self.profile_id = profile_id self.profile_id = profile_id
self.profile_name = nextdns.get_profile_name(profile_id)
self.device_info = DeviceInfo(
configuration_url=f"https://my.nextdns.io/{profile_id}/setup",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(profile_id))},
manufacturer="NextDNS Inc.",
name=self.profile_name,
)
super().__init__( super().__init__(
hass, hass,

View File

@@ -0,0 +1,31 @@
"""Define NextDNS entities."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator
class NextDnsEntity(CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]]):
"""Define NextDNS entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: NextDnsUpdateCoordinator[CoordinatorDataT],
description: EntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(coordinator.profile_id))},
manufacturer="NextDNS Inc.",
name=coordinator.nextdns.get_profile_name(coordinator.profile_id),
)
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self.entity_description = description

View File

@@ -20,10 +20,9 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .const import ( from .const import (
@@ -33,9 +32,10 @@ from .const import (
ATTR_PROTOCOLS, ATTR_PROTOCOLS,
ATTR_STATUS, ATTR_STATUS,
) )
from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator from .coordinator import CoordinatorDataT
from .entity import NextDnsEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -297,27 +297,12 @@ async def async_setup_entry(
) )
class NextDnsSensor( class NextDnsSensor(NextDnsEntity, SensorEntity):
CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]], SensorEntity
):
"""Define an NextDNS sensor.""" """Define an NextDNS sensor."""
_attr_has_entity_name = True entity_description: NextDnsSensorEntityDescription
def __init__( @property
self, def native_value(self) -> StateType:
coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], """Return the state of the sensor."""
description: NextDnsSensorEntityDescription, return self.entity_description.value(self.coordinator.data)
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self._attr_native_value = description.value(coordinator.data)
self.entity_description: NextDnsSensorEntityDescription = description
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = self.entity_description.value(self.coordinator.data)
self.async_write_ha_state()

View File

@@ -4,16 +4,25 @@
"user": { "user": {
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key for your NextDNS account"
} }
}, },
"profiles": { "profiles": {
"data": { "data": {
"profile": "Profile" "profile_name": "Profile"
},
"data_description": {
"profile_name": "The NextDNS configuration profile you want to integrate"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
} }
} }
}, },

View File

@@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator from .coordinator import NextDnsUpdateCoordinator
from .entity import NextDnsEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@@ -536,12 +536,9 @@ async def async_setup_entry(
) )
class NextDnsSwitch( class NextDnsSwitch(NextDnsEntity, SwitchEntity):
CoordinatorEntity[NextDnsUpdateCoordinator[Settings]], SwitchEntity
):
"""Define an NextDNS switch.""" """Define an NextDNS switch."""
_attr_has_entity_name = True
entity_description: NextDnsSwitchEntityDescription entity_description: NextDnsSwitchEntityDescription
def __init__( def __init__(
@@ -550,11 +547,8 @@ class NextDnsSwitch(
description: NextDnsSwitchEntityDescription, description: NextDnsSwitchEntityDescription,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(coordinator) super().__init__(coordinator, description)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self._attr_is_on = description.state(coordinator.data) self._attr_is_on = description.state(coordinator.data)
self.entity_description = description
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:

View File

@@ -219,11 +219,11 @@ class OllamaConversationEntity(
settings = {**self.entry.data, **self.entry.options} settings = {**self.entry.data, **self.entry.options}
try: try:
await chat_log.async_update_llm_data( await chat_log.async_provide_llm_data(
DOMAIN, user_input.as_llm_context(DOMAIN),
user_input,
settings.get(CONF_LLM_HASS_API), settings.get(CONF_LLM_HASS_API),
settings.get(CONF_PROMPT), settings.get(CONF_PROMPT),
user_input.extra_system_prompt,
) )
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()

View File

@@ -66,6 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
translation_domain=DOMAIN, translation_key="authentication_failed" translation_domain=DOMAIN, translation_key="authentication_failed"
) from err ) from err
except OneDriveException as err: except OneDriveException as err:
_LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True)
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_failed" translation_domain=DOMAIN, translation_key="update_failed"
) from err ) from err

View File

@@ -279,11 +279,11 @@ class OpenAIConversationEntity(
options = self.entry.options options = self.entry.options
try: try:
await chat_log.async_update_llm_data( await chat_log.async_provide_llm_data(
DOMAIN, user_input.as_llm_context(DOMAIN),
user_input,
options.get(CONF_LLM_HASS_API), options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT), options.get(CONF_PROMPT),
user_input.extra_system_prompt,
) )
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/osoenergy", "documentation": "https://www.home-assistant.io/integrations/osoenergy",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["pyosoenergyapi==1.1.4"] "requirements": ["pyosoenergyapi==1.1.5"]
} }

View File

@@ -9,7 +9,7 @@ from pypaperless.exceptions import (
PaperlessInvalidTokenError, PaperlessInvalidTokenError,
) )
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
@@ -69,7 +69,7 @@ async def _get_paperless_api(
api = Paperless( api = Paperless(
entry.data[CONF_URL], entry.data[CONF_URL],
entry.data[CONF_API_KEY], entry.data[CONF_API_KEY],
session=async_get_clientsession(hass), session=async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)),
) )
try: try:

View File

@@ -16,7 +16,7 @@ from pypaperless.exceptions import (
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
@@ -25,6 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_URL): str, vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str, vol.Required(CONF_API_KEY): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
} }
) )
@@ -78,15 +79,19 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors: if not errors:
return self.async_update_reload_and_abort(entry, data=user_input) return self.async_update_reload_and_abort(entry, data=user_input)
if user_input is not None:
suggested_values = user_input
else:
suggested_values = {
CONF_URL: entry.data[CONF_URL],
CONF_VERIFY_SSL: entry.data.get(CONF_VERIFY_SSL, True),
}
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, data_schema=STEP_USER_DATA_SCHEMA,
suggested_values={ suggested_values=suggested_values,
CONF_URL: user_input[CONF_URL]
if user_input is not None
else entry.data[CONF_URL],
},
), ),
errors=errors, errors=errors,
) )
@@ -122,13 +127,15 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
errors: dict[str, str] = {} errors: dict[str, str] = {}
client = Paperless( client = Paperless(
user_input[CONF_URL], user_input[CONF_URL],
user_input[CONF_API_KEY], user_input[CONF_API_KEY],
session=async_get_clientsession(self.hass), session=async_get_clientsession(
self.hass, user_input.get(CONF_VERIFY_SSL, True)
),
) )
try: try:

View File

@@ -4,11 +4,13 @@
"user": { "user": {
"data": { "data": {
"url": "[%key:common::config_flow::data::url%]", "url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"data_description": { "data_description": {
"url": "URL to connect to the Paperless-ngx instance", "url": "URL to connect to the Paperless-ngx instance",
"api_key": "API key to connect to the Paperless-ngx API" "api_key": "API key to connect to the Paperless-ngx API",
"verify_ssl": "Verify the SSL certificate of the Paperless-ngx instance. Disable this option if youre using a self-signed certificate."
}, },
"title": "Add Paperless-ngx instance" "title": "Add Paperless-ngx instance"
}, },
@@ -24,11 +26,13 @@
"reconfigure": { "reconfigure": {
"data": { "data": {
"url": "[%key:common::config_flow::data::url%]", "url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"data_description": { "data_description": {
"url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]",
"api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]",
"verify_ssl": "[%key:component::paperless_ngx::config::step::user::data_description::verify_ssl%]"
}, },
"title": "Reconfigure Paperless-ngx instance" "title": "Reconfigure Paperless-ngx instance"
} }

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["ical"], "loggers": ["ical"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["ical==10.0.0"] "requirements": ["ical==10.0.4"]
} }

View File

@@ -75,7 +75,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
) )
http_s = "https" if self._host.api.use_https else "http" http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" if self._host.api.baichuan_only:
self._conf_url = None
else:
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
self._dev_id = self._host.unique_id self._dev_id = self._host.unique_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)}, identifiers={(DOMAIN, self._dev_id)},
@@ -184,6 +187,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
if mac := self._host.api.baichuan.mac_address(dev_ch): if mac := self._host.api.baichuan.mac_address(dev_ch):
connections.add((CONNECTION_NETWORK_MAC, mac)) connections.add((CONNECTION_NETWORK_MAC, mac))
if self._conf_url is None:
conf_url = None
else:
conf_url = f"{self._conf_url}/?ch={dev_ch}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)}, identifiers={(DOMAIN, self._dev_id)},
connections=connections, connections=connections,
@@ -195,7 +203,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
hw_version=self._host.api.camera_hardware_version(dev_ch), hw_version=self._host.api.camera_hardware_version(dev_ch),
sw_version=self._host.api.camera_sw_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch),
serial_number=self._host.api.camera_uid(dev_ch), serial_number=self._host.api.camera_uid(dev_ch),
configuration_url=f"{self._conf_url}/?ch={dev_ch}", configuration_url=conf_url,
) )
@property @property

View File

@@ -220,6 +220,9 @@
"ai_animal_sensitivity": { "ai_animal_sensitivity": {
"default": "mdi:paw" "default": "mdi:paw"
}, },
"cry_sensitivity": {
"default": "mdi:emoticon-cry-outline"
},
"crossline_sensitivity": { "crossline_sensitivity": {
"default": "mdi:fence" "default": "mdi:fence"
}, },
@@ -488,6 +491,12 @@
"state": { "state": {
"on": "mdi:eye-off" "on": "mdi:eye-off"
} }
},
"privacy_mask": {
"default": "mdi:eye",
"state": {
"on": "mdi:eye-off"
}
} }
} }
}, },

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["reolink-aio==0.14.0"] "requirements": ["reolink-aio==0.14.1"]
} }

View File

@@ -272,6 +272,18 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"),
method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"),
), ),
ReolinkNumberEntityDescription(
key="cry_sensitivity",
cmd_key="299",
translation_key="cry_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
native_min_value=1,
native_max_value=5,
supported=lambda api, ch: api.supported(ch, "ai_cry"),
value=lambda api, ch: api.baichuan.cry_sensitivity(ch),
method=lambda api, ch, value: api.baichuan.set_cry_detection(ch, int(value)),
),
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="ai_face_delay", key="ai_face_delay",
cmd_key="GetAiAlarm", cmd_key="GetAiAlarm",

View File

@@ -571,6 +571,9 @@
"ai_animal_sensitivity": { "ai_animal_sensitivity": {
"name": "AI animal sensitivity" "name": "AI animal sensitivity"
}, },
"cry_sensitivity": {
"name": "Baby cry sensitivity"
},
"crossline_sensitivity": { "crossline_sensitivity": {
"name": "AI crossline {zone_name} sensitivity" "name": "AI crossline {zone_name} sensitivity"
}, },
@@ -957,6 +960,9 @@
}, },
"privacy_mode": { "privacy_mode": {
"name": "Privacy mode" "name": "Privacy mode"
},
"privacy_mask": {
"name": "Privacy mask"
} }
} }
} }

View File

@@ -216,6 +216,15 @@ SWITCH_ENTITIES = (
value=lambda api, ch: api.baichuan.privacy_mode(ch), value=lambda api, ch: api.baichuan.privacy_mode(ch),
method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value),
), ),
ReolinkSwitchEntityDescription(
key="privacy_mask",
cmd_key="GetMask",
translation_key="privacy_mask",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "privacy_mask"),
value=lambda api, ch: api.privacy_mask_enabled(ch),
method=lambda api, ch, value: api.set_privacy_mask(ch, enable=value),
),
ReolinkSwitchEntityDescription( ReolinkSwitchEntityDescription(
key="hardwired_chime_enabled", key="hardwired_chime_enabled",
cmd_key="483", cmd_key="483",

View File

@@ -6,5 +6,5 @@
"iot_class": "assumed_state", "iot_class": "assumed_state",
"loggers": ["rflink"], "loggers": ["rflink"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["rflink==0.0.66"] "requirements": ["rflink==0.0.67"]
} }

View File

@@ -10,7 +10,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["rokuecp"], "loggers": ["rokuecp"],
"requirements": ["rokuecp==0.19.3"], "requirements": ["rokuecp==0.19.5"],
"ssdp": [ "ssdp": [
{ {
"st": "roku:ecp", "st": "roku:ecp",

View File

@@ -7,6 +7,6 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiorussound"], "loggers": ["aiorussound"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aiorussound==4.5.2"], "requirements": ["aiorussound==4.6.0"],
"zeroconf": ["_rio._tcp.local."] "zeroconf": ["_rio._tcp.local."]
} }

View File

@@ -235,11 +235,15 @@ class ShellyButton(ShellyBaseButton):
self._attr_unique_id = f"{coordinator.mac}_{description.key}" self._attr_unique_id = f"{coordinator.mac}_{description.key}"
if isinstance(coordinator, ShellyBlockCoordinator): if isinstance(coordinator, ShellyBlockCoordinator):
self._attr_device_info = get_block_device_info( self._attr_device_info = get_block_device_info(
coordinator.device, coordinator.mac coordinator.device,
coordinator.mac,
suggested_area=coordinator.suggested_area,
) )
else: else:
self._attr_device_info = get_rpc_device_info( self._attr_device_info = get_rpc_device_info(
coordinator.device, coordinator.mac coordinator.device,
coordinator.mac,
suggested_area=coordinator.suggested_area,
) )
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}

View File

@@ -211,7 +211,10 @@ class BlockSleepingClimate(
elif entry is not None: elif entry is not None:
self._unique_id = entry.unique_id self._unique_id = entry.unique_id
self._attr_device_info = get_block_device_info( self._attr_device_info = get_block_device_info(
coordinator.device, coordinator.mac, sensor_block coordinator.device,
coordinator.mac,
sensor_block,
suggested_area=coordinator.suggested_area,
) )
self._attr_name = get_block_entity_name( self._attr_name = get_block_entity_name(
self.coordinator.device, sensor_block, None self.coordinator.device, sensor_block, None

View File

@@ -31,7 +31,11 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -114,6 +118,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
self.device = device self.device = device
self.device_id: str | None = None self.device_id: str | None = None
self._pending_platforms: list[Platform] | None = None self._pending_platforms: list[Platform] | None = None
self.suggested_area: str | None = None
device_name = device.name if device.initialized else entry.title device_name = device.name if device.initialized else entry.title
interval_td = timedelta(seconds=update_interval) interval_td = timedelta(seconds=update_interval)
# The device has come online at least once. In the case of a sleeping RPC # The device has come online at least once. In the case of a sleeping RPC
@@ -176,6 +181,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
hw_version=f"gen{get_device_entry_gen(self.config_entry)}", hw_version=f"gen{get_device_entry_gen(self.config_entry)}",
configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}",
) )
# We want to use the main device area as the suggested area for sub-devices.
if (area_id := device_entry.area_id) is not None:
area_registry = ar.async_get(self.hass)
if (area := area_registry.async_get_area(area_id)) is not None:
self.suggested_area = area.name
self.device_id = device_entry.id self.device_id = device_entry.id
async def shutdown(self) -> None: async def shutdown(self) -> None:

View File

@@ -362,7 +362,10 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
self.block = block self.block = block
self._attr_name = get_block_entity_name(coordinator.device, block) self._attr_name = get_block_entity_name(coordinator.device, block)
self._attr_device_info = get_block_device_info( self._attr_device_info = get_block_device_info(
coordinator.device, coordinator.mac, block coordinator.device,
coordinator.mac,
block,
suggested_area=coordinator.suggested_area,
) )
self._attr_unique_id = f"{coordinator.mac}-{block.description}" self._attr_unique_id = f"{coordinator.mac}-{block.description}"
@@ -405,7 +408,10 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
super().__init__(coordinator) super().__init__(coordinator)
self.key = key self.key = key
self._attr_device_info = get_rpc_device_info( self._attr_device_info = get_rpc_device_info(
coordinator.device, coordinator.mac, key coordinator.device,
coordinator.mac,
key,
suggested_area=coordinator.suggested_area,
) )
self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key) self._attr_name = get_rpc_entity_name(coordinator.device, key)
@@ -521,7 +527,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
) )
self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_unique_id = f"{coordinator.mac}-{attribute}"
self._attr_device_info = get_block_device_info( self._attr_device_info = get_block_device_info(
coordinator.device, coordinator.mac coordinator.device,
coordinator.mac,
suggested_area=coordinator.suggested_area,
) )
self._last_value = None self._last_value = None
@@ -630,7 +638,10 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
self.entity_description = description self.entity_description = description
self._attr_device_info = get_block_device_info( self._attr_device_info = get_block_device_info(
coordinator.device, coordinator.mac, block coordinator.device,
coordinator.mac,
block,
suggested_area=coordinator.suggested_area,
) )
if block is not None: if block is not None:
@@ -698,7 +709,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
self.entity_description = description self.entity_description = description
self._attr_device_info = get_rpc_device_info( self._attr_device_info = get_rpc_device_info(
coordinator.device, coordinator.mac, key coordinator.device,
coordinator.mac,
key,
suggested_area=coordinator.suggested_area,
) )
self._attr_unique_id = self._attr_unique_id = ( self._attr_unique_id = self._attr_unique_id = (
f"{coordinator.mac}-{key}-{attribute}" f"{coordinator.mac}-{key}-{attribute}"

View File

@@ -207,7 +207,10 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
super().__init__(coordinator) super().__init__(coordinator)
self.event_id = int(key.split(":")[-1]) self.event_id = int(key.split(":")[-1])
self._attr_device_info = get_rpc_device_info( self._attr_device_info = get_rpc_device_info(
coordinator.device, coordinator.mac, key coordinator.device,
coordinator.mac,
key,
suggested_area=coordinator.suggested_area,
) )
self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key) self._attr_name = get_rpc_entity_name(coordinator.device, key)

View File

@@ -139,7 +139,11 @@ class RpcEmeterPhaseSensor(RpcSensor):
super().__init__(coordinator, key, attribute, description) super().__init__(coordinator, key, attribute, description)
self._attr_device_info = get_rpc_device_info( self._attr_device_info = get_rpc_device_info(
coordinator.device, coordinator.mac, key, description.emeter_phase coordinator.device,
coordinator.mac,
key,
emeter_phase=description.emeter_phase,
suggested_area=coordinator.suggested_area,
) )

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