Merge branch 'dev' into gj-20260704-04

This commit is contained in:
G Johansson
2026-07-04 15:18:45 +02:00
committed by GitHub
252 changed files with 9316 additions and 1314 deletions
+1
View File
@@ -95,6 +95,7 @@ components: &components
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/llm/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**
+1
View File
@@ -347,6 +347,7 @@ homeassistant.components.light.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.*
homeassistant.components.litterrobot.*
homeassistant.components.llama_cpp.*
homeassistant.components.local_ip.*
homeassistant.components.local_todo.*
homeassistant.components.lock.*
Generated
+4
View File
@@ -1026,6 +1026,10 @@ CLAUDE.md @home-assistant/core
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
/tests/components/livisi/ @StefanIacobLivisi @planbnet
/homeassistant/components/llama_cpp/ @allenporter
/tests/components/llama_cpp/ @allenporter
/homeassistant/components/llm/ @home-assistant/core
/tests/components/llm/ @home-assistant/core
/homeassistant/components/local_calendar/ @allenporter
/tests/components/local_calendar/ @allenporter
/homeassistant/components/local_ip/ @issacg
+9 -3
View File
@@ -32,6 +32,7 @@ class AirobotButtonEntityDescription(ButtonEntityDescription):
"""Describes Airobot button entity."""
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
ignore_connection_errors: bool = False
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
@@ -40,6 +41,7 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
ignore_connection_errors=True,
),
AirobotButtonEntityDescription(
key="recalibrate_co2",
@@ -84,10 +86,14 @@ class AirobotButton(AirobotEntity, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
# pylint: disable-next=home-assistant-action-swallowed-exception
except AirobotConnectionError, AirobotTimeoutError:
except (AirobotConnectionError, AirobotTimeoutError) as err:
if not self.entity_description.ignore_connection_errors:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
translation_placeholders={"button": self.entity_description.key},
) from err
# Connection errors during reboot are expected as device restarts
pass
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -219,6 +219,21 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b
entry, version=new_version, minor_version=new_minor_version
)
if entry.version == 2:
new_version = 3
new_minor_version = 1
new_data = {**entry.data}
if "advanced_settings" in new_data:
new_data[SECTION_ADDITIONAL_SETTINGS] = new_data.pop("advanced_settings")
hass.config_entries.async_update_entry(
entry,
data=new_data,
version=new_version,
minor_version=new_minor_version,
)
return True
@@ -90,7 +90,7 @@ STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend(
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
VERSION = 2
VERSION = 3
MINOR_VERSION = 1
_discovery_task: asyncio.Task | None = None
@@ -5,9 +5,6 @@ from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble"
MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300
@@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import (
DEFAULT_SCAN_INTERVAL,
@@ -36,9 +35,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None:
"""Initialize the coordinator."""
self.airthings = AirthingsBluetoothDeviceData(
_LOGGER, hass.config.units is METRIC_SYSTEM
)
self.airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric=True)
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
@@ -9,15 +9,9 @@
"smartlink": "mdi:hub"
}
},
"radon_1day_avg": {
"default": "mdi:radioactive"
},
"radon_1day_level": {
"default": "mdi:radioactive"
},
"radon_longterm_avg": {
"default": "mdi:radioactive"
},
"radon_longterm_level": {
"default": "mdi:radioactive"
}
@@ -1,7 +1,6 @@
"""Support for airthings ble sensors."""
from collections.abc import Callable
import dataclasses
from dataclasses import dataclass
import logging
from typing import override
@@ -19,6 +18,7 @@ from homeassistant.const import (
EntityCategory,
Platform,
UnitOfPressure,
UnitOfRadiationConcentration,
UnitOfRatio,
UnitOfSoundPressure,
UnitOfTemperature,
@@ -33,9 +33,8 @@ from homeassistant.helpers.entity_registry import (
)
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE
from .const import DOMAIN
from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -65,15 +64,15 @@ SENSORS_MAPPING_TEMPLATE: dict[str, AirthingsBLESensorEntityDescription] = {
"radon_1day_avg": AirthingsBLESensorEntityDescription(
key="radon_1day_avg",
translation_key="radon_1day_avg",
native_unit_of_measurement=VOLUME_BECQUEREL,
suggested_display_precision=0,
device_class=SensorDeviceClass.RADON,
native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"radon_longterm_avg": AirthingsBLESensorEntityDescription(
key="radon_longterm_avg",
translation_key="radon_longterm_avg",
native_unit_of_measurement=VOLUME_BECQUEREL,
suggested_display_precision=0,
device_class=SensorDeviceClass.RADON,
native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"radon_1day_level": AirthingsBLESensorEntityDescription(
@@ -210,26 +209,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airthings BLE sensors."""
is_metric = hass.config.units is METRIC_SYSTEM
coordinator = entry.runtime_data
# we need to change some units
sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy()
if not is_metric:
for key, val in sensors_mapping.items():
if val.native_unit_of_measurement is not VOLUME_BECQUEREL:
continue
sensors_mapping[key] = dataclasses.replace(
val,
native_unit_of_measurement=VOLUME_PICOCURIE,
suggested_display_precision=1,
)
entities = []
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)
for sensor_type, sensor_value in coordinator.data.sensors.items():
if sensor_type not in sensors_mapping:
if sensor_type not in SENSORS_MAPPING_TEMPLATE:
_LOGGER.debug(
"Unknown sensor type detected: %s, %s",
sensor_type,
@@ -238,7 +223,9 @@ async def async_setup_entry(
continue
async_migrate(hass, coordinator.data.address, sensor_type)
entities.append(
AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type])
AirthingsSensor(
coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type]
)
)
async_add_entities(entities)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.1.8"]
"requirements": ["aioamazondevices==14.1.9"]
}
+11 -7
View File
@@ -46,6 +46,7 @@ from homeassistant.exceptions import (
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, recorder, template
from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE
from homeassistant.helpers.json import json_dumps, json_fragment
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType
@@ -223,12 +224,14 @@ class APIStatesView(HomeAssistantView):
for state in hass.states.async_all()
if entity_perm(state.entity_id, POLICY_READ)
)
body = b"".join((b"[", b",".join(states), b"]"))
response = web.Response(
body=b"".join((b"[", b",".join(states), b"]")),
body=body,
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
)
response.enable_compression()
if len(body) > MIN_COMPRESSED_RESPONSE_SIZE:
response.enable_compression()
return response
@@ -297,11 +300,12 @@ class APIEntityStateView(HomeAssistantView):
return self.json_message(
"Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR
)
resp = self.json(state.as_dict(), status_code)
resp.headers.add("Location", f"/api/states/{entity_id}")
return resp
return web.Response(
body=state.as_dict_json,
content_type=CONTENT_TYPE_JSON,
status=status_code,
headers={"Location": f"/api/states/{entity_id}"},
)
@ha.callback
def delete(self, request: web.Request, entity_id: str) -> web.Response:
+1 -1
View File
@@ -1,4 +1,4 @@
"""The aurora component."""
"""The Aurora integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -1,4 +1,4 @@
"""The aurora component."""
"""The Aurora integration."""
from datetime import timedelta
import logging
+1 -1
View File
@@ -1,4 +1,4 @@
"""The aurora component."""
"""The Aurora integration."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+106
View File
@@ -0,0 +1,106 @@
"""LLM tools for the calendar integration."""
from datetime import timedelta
from operator import attrgetter
from typing import cast, override
import voluptuous as vol
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, intent
from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType
from . import SERVICE_GET_EVENTS
from .const import DOMAIN
class CalendarGetEventsTool(Tool):
"""LLM Tool allowing querying a calendar."""
name = "calendar_get_events"
description = (
"Get events from a calendar. "
"When asked if something happens, search the whole week. "
"Results are RFC 5545 which means 'end' is exclusive."
)
def __init__(self, calendars: list[str]) -> None:
"""Init the get events tool."""
self.parameters = vol.Schema(
{
vol.Required("calendar"): vol.In(calendars),
vol.Required("range"): vol.In(["today", "week"]),
}
)
@override
async def async_call(
self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
) -> JsonObjectType:
"""Query a calendar."""
data = self.parameters(tool_input.tool_args)
result = intent.async_match_targets(
hass,
intent.MatchTargetsConstraints(
name=data["calendar"],
domains=[DOMAIN],
assistant=llm_context.assistant,
),
)
if not result.is_match:
return {"success": False, "error": "Calendar not found"}
entity_id = result.states[0].entity_id
if data["range"] == "today":
start = dt_util.now()
end = dt_util.start_of_local_day() + timedelta(days=1)
elif data["range"] == "week":
start = dt_util.now()
end = dt_util.start_of_local_day() + timedelta(days=7)
service_data = {
"entity_id": entity_id,
"start_date_time": start.isoformat(),
"end_date_time": end.isoformat(),
}
service_result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_EVENTS,
service_data,
context=llm_context.context,
blocking=True,
return_response=True,
)
events = [
event if "T" in event["start"] else {**event, "all_day": True}
for event in cast(dict, service_result)[entity_id]["events"]
]
return {"success": True, "result": events}
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return the calendar LLM tools when a calendar is exposed."""
if api_id != LLM_API_ASSIST:
return None
entity_registry = er.async_get(hass)
names: list[str] = []
for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")):
if not async_should_expose(hass, llm_context.assistant, state.entity_id):
continue
entity_entry = entity_registry.async_get(state.entity_id)
names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state))
if not names:
return None
return LLMTools(tools=[CalendarGetEventsTool(names)])
+1 -23
View File
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -103,28 +103,6 @@ class CieloClimate(CieloDeviceEntity, ClimateEntity):
super().__init__(coordinator, device_id)
self._attr_unique_id = device_id
@property
@override
def temperature_unit(self) -> str:
"""Return the unit of temperature in Home Assistant format.
It can change over time based on the device settings,
so we fetch it dynamically from the client.
"""
unit = self.client.temperature_unit()
if not unit:
return UnitOfTemperature.CELSIUS
normalized = unit.strip().lower()
if normalized in {"c", "°c", "celsius"}:
return UnitOfTemperature.CELSIUS
if normalized in {"f", "°f", "fahrenheit"}:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
@override
def supported_features(self) -> ClimateEntityFeature:
@@ -11,12 +11,16 @@ from homeassistant.const import Platform
DOMAIN: Final = "cielo_home"
PLATFORMS: Final[list[Platform]] = [
Platform.CLIMATE,
Platform.SENSOR,
]
DEFAULT_NAME: Final = "Cielo Home"
DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60
TIMEOUT: Final[int] = 20
LOGGER: Final = logging.getLogger(__package__)
SENSOR_TEMPERATURE: Final = "temperature"
SENSOR_HUMIDITY: Final = "humidity"
CIELO_ERRORS: Final[tuple] = (
ClientError,
TimeoutError,
@@ -5,6 +5,7 @@ from typing import override
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.const import UnitOfTemperature
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -12,6 +13,26 @@ from .const import DOMAIN
from .coordinator import CieloDataUpdateCoordinator
def normalize_temp_unit(client: CieloDeviceAPI) -> str:
"""Normalize a raw device temperature unit to a UnitOfTemperature value.
Unrecognized or empty values fall back to Celsius.
"""
unit = client.temperature_unit()
if not unit:
return UnitOfTemperature.CELSIUS
normalized = unit.strip().lower()
if normalized in {"c", "°c", "celsius"}:
return UnitOfTemperature.CELSIUS
if normalized in {"f", "°f", "fahrenheit"}:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]):
"""Representation of a Cielo base entity."""
@@ -74,3 +95,14 @@ class CieloDeviceEntity(CieloBaseEntity):
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
)
@property
def temperature_unit(self) -> str:
"""Return the unit of temperature for the device.
The unit can change over time based on the device settings,
so it is fetched dynamically from the client. This dynamic
nature means that if a user changes the device's temperature
unit, historical statistics may be affected.
"""
return normalize_temp_unit(self.client)
@@ -0,0 +1,101 @@
"""Support for Cielo Home sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SENSOR_HUMIDITY, SENSOR_TEMPERATURE
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
from .entity import CieloDeviceEntity, normalize_temp_unit
@dataclass(kw_only=True, frozen=True)
class CieloSensorEntityDescription(SensorEntityDescription):
"""Describes a Cielo Home sensor entity."""
value_fn: Callable[[CieloDeviceAPI, CieloDevice | None], float | int | None]
unit_fn: Callable[[CieloDeviceAPI], str | None] | None = None
SENSOR_DESCRIPTIONS: tuple[CieloSensorEntityDescription, ...] = (
CieloSensorEntityDescription(
key=SENSOR_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda client, device_data: client.current_temperature(),
# Temperature unit is dynamic; see the native_unit_of_measurement property for limitations.
unit_fn=normalize_temp_unit,
),
CieloSensorEntityDescription(
key=SENSOR_HUMIDITY,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda client, device_data: (
device_data.humidity if device_data else None
),
),
)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: CieloHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cielo Home sensors."""
coordinator = entry.runtime_data
entities = [
CieloSensor(coordinator, device_id, description)
for device_id in coordinator.data.parsed
for description in SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
class CieloSensor(CieloDeviceEntity, SensorEntity):
"""Representation of a Cielo Home sensor."""
entity_description: CieloSensorEntityDescription
def __init__(
self,
coordinator: CieloDataUpdateCoordinator,
device_id: str,
entity_description: CieloSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id)
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
@property
@override
def native_value(self) -> float | int | None:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.client, self.device_data)
@property
@override
def native_unit_of_measurement(self) -> str | None:
"""Return the native unit of measurement."""
if self.entity_description.unit_fn is not None:
return self.entity_description.unit_fn(self.client)
return super().native_unit_of_measurement
+17 -9
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, override
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.const import CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -19,7 +19,7 @@ from homeassistant.helpers.condition import (
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
from .const import DOMAIN, ClimateEntityStateAttribute, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
@@ -57,7 +57,9 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Mixin for climate target temperature conditions with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {
DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.TEMPERATURE)
}
_unit_converter = TemperatureConverter
@override
@@ -65,7 +67,8 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE)
is not None
)
@override
@@ -78,7 +81,9 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_domain_specs = {
DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HUMIDITY)
}
_valid_unit = "%"
@override
@@ -86,7 +91,7 @@ class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
and state.attributes.get(ClimateEntityStateAttribute.HUMIDITY) is not None
)
@@ -105,13 +110,16 @@ CONDITIONS: dict[str, type[Condition]] = {
},
),
"is_cooling": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.COOLING,
),
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.DRYING,
),
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.HEATING,
),
"is_target_humidity": ClimateTargetHumidityCondition,
"is_target_temperature": ClimateTargetTemperatureCondition,
@@ -110,7 +110,12 @@ async def async_get_action_capabilities(
try:
entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid)
hvac_modes = (
get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or []
get_capability(
hass,
entry.entity_id,
const.ClimateEntityCapabilityAttribute.HVAC_MODES,
)
or []
)
except HomeAssistantError:
hvac_modes = []
@@ -119,7 +124,12 @@ async def async_get_action_capabilities(
try:
entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid)
preset_modes = (
get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or []
get_capability(
hass,
entry.entity_id,
const.ClimateEntityCapabilityAttribute.PRESET_MODES,
)
or []
)
except HomeAssistantError:
preset_modes = []
@@ -94,7 +94,7 @@ def async_condition_from_config(
return bool(state.state == config[const.ATTR_HVAC_MODE])
return bool(
state.attributes.get(const.ATTR_PRESET_MODE)
state.attributes.get(const.ClimateEntityStateAttribute.PRESET_MODE)
== config[const.ATTR_PRESET_MODE]
)
@@ -115,7 +115,12 @@ async def async_get_condition_capabilities(
hass, config[CONF_ENTITY_ID]
)
hvac_modes = (
get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or []
get_capability(
hass,
entry.entity_id,
const.ClimateEntityCapabilityAttribute.HVAC_MODES,
)
or []
)
except HomeAssistantError:
hvac_modes = []
@@ -127,7 +132,12 @@ async def async_get_condition_capabilities(
hass, config[CONF_ENTITY_ID]
)
preset_modes = (
get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or []
get_capability(
hass,
entry.entity_id,
const.ClimateEntityCapabilityAttribute.PRESET_MODES,
)
or []
)
except HomeAssistantError:
preset_modes = []
@@ -87,7 +87,11 @@ async def async_get_triggers(
}
)
if state and const.ATTR_CURRENT_TEMPERATURE in state.attributes:
if (
state
and const.ClimateEntityStateAttribute.CURRENT_TEMPERATURE
in state.attributes
):
triggers.append(
{
**base_trigger,
@@ -95,7 +99,10 @@ async def async_get_triggers(
}
)
if state and const.ATTR_CURRENT_HUMIDITY in state.attributes:
if (
state
and const.ClimateEntityStateAttribute.CURRENT_HUMIDITY in state.attributes
):
triggers.append(
{
**base_trigger,
+37
View File
@@ -0,0 +1,37 @@
"""LLM tools for the climate integration."""
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from .const import DOMAIN, INTENT_SET_TEMPERATURE
# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_SET_TEMPERATURE,)
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return LLM tools for the integration's intents when its domain is exposed."""
if api_id != LLM_API_ASSIST:
return None
if not llm_context.assistant:
return None
if not any(
async_should_expose(hass, llm_context.assistant, state.entity_id)
for state in hass.states.async_all(DOMAIN)
):
return None
tools: list[Tool] = [
IntentTool(handler.intent_type, handler)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
return LLMTools(tools=tools)
@@ -25,8 +25,21 @@ from .const import (
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
ClimateEntityStateAttribute,
)
# Maps a state attribute to the service call argument used to restore it.
_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[ClimateEntityStateAttribute, str] = {
ClimateEntityStateAttribute.TEMPERATURE: ATTR_TEMPERATURE,
ClimateEntityStateAttribute.TARGET_TEMP_HIGH: ATTR_TARGET_TEMP_HIGH,
ClimateEntityStateAttribute.TARGET_TEMP_LOW: ATTR_TARGET_TEMP_LOW,
ClimateEntityStateAttribute.PRESET_MODE: ATTR_PRESET_MODE,
ClimateEntityStateAttribute.SWING_MODE: ATTR_SWING_MODE,
ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE: ATTR_SWING_HORIZONTAL_MODE,
ClimateEntityStateAttribute.FAN_MODE: ATTR_FAN_MODE,
ClimateEntityStateAttribute.HUMIDITY: ATTR_HUMIDITY,
}
async def _async_reproduce_states(
hass: HomeAssistant,
@@ -38,14 +51,16 @@ async def _async_reproduce_states(
"""Reproduce component states."""
async def call_service(
service: str, keys: Iterable, data: dict[str, Any] | None = None
service: str,
attributes: Iterable[ClimateEntityStateAttribute],
data: dict[str, Any] | None = None,
) -> None:
"""Call service with set of attributes given."""
"""Call service with the given state attributes."""
data = data or {}
data["entity_id"] = state.entity_id
for key in keys:
if (value := state.attributes.get(key)) is not None:
data[key] = value
for attribute in attributes:
if (value := state.attributes.get(attribute)) is not None:
data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = value
await hass.services.async_call(
DOMAIN, service, data, blocking=True, context=context
@@ -55,43 +70,54 @@ async def _async_reproduce_states(
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
if (
(state.attributes.get(ATTR_TEMPERATURE) is not None)
or (state.attributes.get(ATTR_TARGET_TEMP_HIGH) is not None)
or (state.attributes.get(ATTR_TARGET_TEMP_LOW) is not None)
state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) is not None
or state.attributes.get(ClimateEntityStateAttribute.TARGET_TEMP_HIGH)
is not None
or state.attributes.get(ClimateEntityStateAttribute.TARGET_TEMP_LOW) is not None
):
await call_service(
SERVICE_SET_TEMPERATURE,
[ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW],
[
ClimateEntityStateAttribute.TEMPERATURE,
ClimateEntityStateAttribute.TARGET_TEMP_HIGH,
ClimateEntityStateAttribute.TARGET_TEMP_LOW,
],
)
if (
ATTR_PRESET_MODE in state.attributes
and state.attributes[ATTR_PRESET_MODE] is not None
):
await call_service(SERVICE_SET_PRESET_MODE, [ATTR_PRESET_MODE])
if (
ATTR_SWING_MODE in state.attributes
and state.attributes[ATTR_SWING_MODE] is not None
):
await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE])
if (
ATTR_SWING_HORIZONTAL_MODE in state.attributes
and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None
ClimateEntityStateAttribute.PRESET_MODE in state.attributes
and state.attributes[ClimateEntityStateAttribute.PRESET_MODE] is not None
):
await call_service(
SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE]
SERVICE_SET_PRESET_MODE, [ClimateEntityStateAttribute.PRESET_MODE]
)
if (
ATTR_FAN_MODE in state.attributes
and state.attributes[ATTR_FAN_MODE] is not None
ClimateEntityStateAttribute.SWING_MODE in state.attributes
and state.attributes[ClimateEntityStateAttribute.SWING_MODE] is not None
):
await call_service(SERVICE_SET_FAN_MODE, [ATTR_FAN_MODE])
await call_service(
SERVICE_SET_SWING_MODE, [ClimateEntityStateAttribute.SWING_MODE]
)
if ATTR_HUMIDITY in state.attributes:
await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY])
if (
ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE in state.attributes
and state.attributes[ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE]
is not None
):
await call_service(
SERVICE_SET_SWING_HORIZONTAL_MODE,
[ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE],
)
if (
ClimateEntityStateAttribute.FAN_MODE in state.attributes
and state.attributes[ClimateEntityStateAttribute.FAN_MODE] is not None
):
await call_service(SERVICE_SET_FAN_MODE, [ClimateEntityStateAttribute.FAN_MODE])
if ClimateEntityStateAttribute.HUMIDITY in state.attributes:
await call_service(SERVICE_SET_HUMIDITY, [ClimateEntityStateAttribute.HUMIDITY])
async def async_reproduce_states(
@@ -9,32 +9,20 @@ from homeassistant.helpers.significant_change import (
check_valid_float,
)
from . import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
)
from . import ClimateEntityStateAttribute
SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
ClimateEntityStateAttribute.CURRENT_HUMIDITY,
ClimateEntityStateAttribute.CURRENT_TEMPERATURE,
ClimateEntityStateAttribute.FAN_MODE,
ClimateEntityStateAttribute.HUMIDITY,
ClimateEntityStateAttribute.HVAC_ACTION,
ClimateEntityStateAttribute.PRESET_MODE,
ClimateEntityStateAttribute.SWING_MODE,
ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE,
ClimateEntityStateAttribute.TARGET_TEMP_HIGH,
ClimateEntityStateAttribute.TARGET_TEMP_LOW,
ClimateEntityStateAttribute.TEMPERATURE,
}
@@ -63,11 +51,11 @@ def async_check_significant_change(
for attr_name in changed_attrs:
if attr_name in [
ATTR_FAN_MODE,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ClimateEntityStateAttribute.FAN_MODE,
ClimateEntityStateAttribute.HVAC_ACTION,
ClimateEntityStateAttribute.PRESET_MODE,
ClimateEntityStateAttribute.SWING_MODE,
ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE,
]:
return True
@@ -83,17 +71,20 @@ def async_check_significant_change(
absolute_change: float | None = None
if attr_name in [
ATTR_CURRENT_TEMPERATURE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
ClimateEntityStateAttribute.CURRENT_TEMPERATURE,
ClimateEntityStateAttribute.TARGET_TEMP_HIGH,
ClimateEntityStateAttribute.TARGET_TEMP_LOW,
ClimateEntityStateAttribute.TEMPERATURE,
]:
if ha_unit == UnitOfTemperature.FAHRENHEIT:
absolute_change = 1.0
else:
absolute_change = 0.5
if attr_name in [ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY]:
if attr_name in [
ClimateEntityStateAttribute.CURRENT_HUMIDITY,
ClimateEntityStateAttribute.HUMIDITY,
]:
absolute_change = 1.0
if absolute_change and check_absolute_change(
+17 -9
View File
@@ -4,7 +4,7 @@ from typing import override
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.const import CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -24,7 +24,7 @@ from homeassistant.helpers.trigger import (
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
from .const import DOMAIN, ClimateEntityStateAttribute, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
@@ -55,7 +55,9 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
"""Mixin for climate target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {
DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.TEMPERATURE)
}
_unit_converter = TemperatureConverter
@override
@@ -63,7 +65,8 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE)
is not None
)
@override
@@ -90,7 +93,9 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_domain_specs = {
DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HUMIDITY)
}
_valid_unit = "%"
@override
@@ -98,7 +103,7 @@ class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
and state.attributes.get(ClimateEntityStateAttribute.HUMIDITY) is not None
)
@@ -117,10 +122,12 @@ class ClimateTargetHumidityCrossedThresholdTrigger(
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.COOLING,
),
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.DRYING,
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
@@ -144,7 +151,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
},
),
"started_heating": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
{DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)},
HVACAction.HEATING,
),
}
@@ -77,6 +77,7 @@ __all__ = [
"CoverEntity",
"CoverEntityDescription",
"CoverEntityFeature",
"CoverEntityStateAttribute",
"CoverState",
"make_cover_closed_trigger",
"make_cover_is_closed_condition",
+7 -3
View File
@@ -7,7 +7,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.condition import Condition, EntityConditionBase
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .const import DOMAIN, CoverDeviceClass, CoverEntityStateAttribute
from .models import CoverDomainSpec
@@ -39,7 +39,9 @@ def make_cover_is_open_condition(
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
value_source=(
CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None
),
target_value=False if domain == DOMAIN else STATE_ON,
)
for domain, dc in device_classes.items()
@@ -59,7 +61,9 @@ def make_cover_is_closed_condition(
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
value_source=(
CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None
),
target_value=True if domain == DOMAIN else STATE_OFF,
)
for domain, dc in device_classes.items()
@@ -21,7 +21,7 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DOMAIN, CoverEntityFeature, CoverState
from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState
# mypy: disallow-any-generics
@@ -137,9 +137,9 @@ def async_condition_from_config(
return test_is_state
if config[CONF_TYPE] == "is_position":
position_attr = "current_position"
position_attr = CoverEntityStateAttribute.CURRENT_POSITION
if config[CONF_TYPE] == "is_tilt_position":
position_attr = "current_tilt_position"
position_attr = CoverEntityStateAttribute.CURRENT_TILT_POSITION
min_pos = config.get(CONF_ABOVE)
max_pos = config.get(CONF_BELOW)
@@ -24,7 +24,7 @@ from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, CoverEntityFeature, CoverState
from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState
POSITION_TRIGGER_TYPES = {"position", "tilt_position"}
STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"}
@@ -164,9 +164,9 @@ async def async_attach_trigger(
)
if config[CONF_TYPE] == "position":
position = "current_position"
position = CoverEntityStateAttribute.CURRENT_POSITION
if config[CONF_TYPE] == "tilt_position":
position = "current_tilt_position"
position = CoverEntityStateAttribute.CURRENT_TILT_POSITION
min_pos = config.get(CONF_ABOVE, -1)
max_pos = config.get(CONF_BELOW, 101)
value_template = f"{{{{ state.attributes.{position} }}}}"
@@ -8,24 +8,23 @@ from typing import Any, Final
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
SERVICE_CLOSE_COVER,
SERVICE_CLOSE_COVER_TILT,
SERVICE_OPEN_COVER,
SERVICE_OPEN_COVER_TILT,
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
EntityStateAttribute,
)
from homeassistant.core import Context, HomeAssistant, ServiceResponse, State
from homeassistant.util.enum import try_parse_enum
from . import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN,
CoverEntityFeature,
CoverEntityStateAttribute,
CoverState,
)
@@ -43,13 +42,13 @@ FULL_CLOSE: Final = 0
def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature:
"""Determine supported features based on current attributes."""
features = CoverEntityFeature(0)
if ATTR_CURRENT_POSITION in current_attrs:
if CoverEntityStateAttribute.CURRENT_POSITION in current_attrs:
features |= (
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
)
if ATTR_CURRENT_TILT_POSITION in current_attrs:
if CoverEntityStateAttribute.CURRENT_TILT_POSITION in current_attrs:
features |= (
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
@@ -183,12 +182,20 @@ async def _async_reproduce_state(
current_attrs = cur_state.attributes
target_attrs = state.attributes
current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION)
target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION)
current_position: int | None = current_attrs.get(
CoverEntityStateAttribute.CURRENT_POSITION
)
target_position: int | None = target_attrs.get(
CoverEntityStateAttribute.CURRENT_POSITION
)
position_matches = current_position == target_position
current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION)
target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION)
current_tilt_position: int | None = current_attrs.get(
CoverEntityStateAttribute.CURRENT_TILT_POSITION
)
target_tilt_position: int | None = target_attrs.get(
CoverEntityStateAttribute.CURRENT_TILT_POSITION
)
tilt_position_matches = current_tilt_position == target_tilt_position
state_matches = cur_state.state == target_state
@@ -197,7 +204,7 @@ async def _async_reproduce_state(
return
features = try_parse_enum(
CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES)
CoverEntityFeature, current_attrs.get(EntityStateAttribute.SUPPORTED_FEATURES)
)
if features is None:
# Backwards compatibility for integrations that
@@ -8,11 +8,11 @@ from homeassistant.helpers.significant_change import (
check_valid_float,
)
from . import ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION
from .const import CoverEntityStateAttribute
SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
CoverEntityStateAttribute.CURRENT_POSITION,
CoverEntityStateAttribute.CURRENT_TILT_POSITION,
}
+7 -3
View File
@@ -11,7 +11,7 @@ from homeassistant.helpers.trigger import (
Trigger,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .const import DOMAIN, CoverDeviceClass, CoverEntityStateAttribute
from .models import CoverDomainSpec
@@ -56,7 +56,9 @@ def make_cover_opened_trigger(
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
value_source=(
CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None
),
target_value=False if domain == DOMAIN else STATE_ON,
)
for domain, dc in device_classes.items()
@@ -76,7 +78,9 @@ def make_cover_closed_trigger(
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
value_source=(
CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None
),
target_value=True if domain == DOMAIN else STATE_OFF,
)
for domain, dc in device_classes.items()
@@ -407,9 +407,9 @@ class DemoTVShowPlayer(AbstractDemoPlayer):
class DemoBrowsePlayer(AbstractDemoPlayer):
"""A Demo media player that supports browse."""
"""A Demo media player that supports browse and search."""
_attr_supported_features = BROWSE_PLAYER_SUPPORT
_attr_supported_features = BROWSE_PLAYER_SUPPORT | SEARCH_PLAYER_SUPPORT
@override
async def async_browse_media(
@@ -421,6 +421,13 @@ class DemoBrowsePlayer(AbstractDemoPlayer):
return await media_source.async_browse_media(self.hass, media_content_id)
@override
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
"""Implement the websocket media search helper by delegating to media source."""
return await media_source.async_search_media(
self.hass, query.media_content_id, query
)
class DemoGroupPlayer(AbstractDemoPlayer):
"""A Demo media player that supports grouping."""
+12 -22
View File
@@ -6,12 +6,14 @@ from typing import Any, override
import evohomeasync2 as evo
from evohomeasync2.const import (
SZ_DURATION,
SZ_MODE,
SZ_PERIOD,
SZ_SETPOINT_STATUS,
SZ_SYSTEM_MODE,
SZ_SYSTEM_MODE_STATUS,
SZ_TEMPERATURE_STATUS,
)
from evohomeasync2.schemas.const import (
SZ_UNTIL,
SystemMode as EvoSystemMode,
ZoneMode as EvoZoneMode,
)
@@ -25,12 +27,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import (
ATTR_MODE,
ATTR_TEMPERATURE,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -38,14 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from .const import (
ATTR_DURATION,
ATTR_PERIOD,
DOMAIN,
EVOHOME_DATA,
RESET_BREAKS_IN_HA_VERSION,
EvoService,
)
from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService
from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id
from .helpers import async_create_deprecation_issue_once
@@ -272,7 +262,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
if (until := kwargs.get("until")) is None:
if (until := kwargs.get(SZ_UNTIL)) is None:
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:
until = self._evo_device.until
if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE:
@@ -397,14 +387,14 @@ class EvoController(EvoClimateEntity):
await self.coordinator.call_client_api(self._evo_device.reset())
return
mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
mode = data[SZ_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
if ATTR_PERIOD in data:
if SZ_PERIOD in data:
until = dt_util.start_of_local_day()
until += data[ATTR_PERIOD]
until += data[SZ_PERIOD]
elif ATTR_DURATION in data:
until = dt_util.now() + data[ATTR_DURATION]
elif SZ_DURATION in data:
until = dt_util.now() + data[SZ_DURATION]
else:
until = None
@@ -20,10 +20,6 @@ CONF_LOCATION_IDX: Final = "location_idx"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_PERIOD: Final = "period" # number of days
ATTR_SETPOINT: Final = "setpoint"
# Support for the refresh_system service is being deprecated
REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0"
# Support for the reset service calls/presets is being deprecated
@@ -20,7 +20,7 @@ from evohomeasync2.const import (
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
SZ_ZONES,
)
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
from evohomeasync2.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
+23 -3
View File
@@ -1,15 +1,20 @@
"""Support for entities of the Evohome integration."""
from collections.abc import Mapping
from datetime import datetime
from enum import StrEnum
import logging
from typing import Any, override
import evohomeasync2 as evo
from evohomeasync2.schemas.const import (
from evohomeasync2.const import (
SZ_SINCE,
SZ_TIME_UNTIL,
SZ_UNTIL,
ZoneModelType as EvoZoneModelType,
ZoneType as EvoZoneType,
)
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from evohomeasync2.typedefs import EvoDayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -20,6 +25,19 @@ from .coordinator import EvoDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
def _recurse_and_revert(val: Any, _key: str | None = None) -> Any:
"""Recursively revert any values to native format."""
if isinstance(val, dict):
return {k: _recurse_and_revert(v, k) for k, v in val.items()}
if isinstance(val, (list, tuple)):
return type(val)(_recurse_and_revert(v) for v in val)
if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL):
return val.isoformat()
if isinstance(val, StrEnum):
return "".join(word.capitalize() for word in val.value.split("_"))
return val
def is_valid_zone(zone: evo.Zone) -> bool:
"""Check if an Evohome zone should have climate and button entities."""
return (
@@ -73,6 +91,8 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
for attr in self._evo_state_attr_names:
self._device_state_attrs[attr] = getattr(self._evo_device, attr)
self._device_state_attrs = _recurse_and_revert(self._device_state_attrs)
super()._handle_coordinator_update()
async def update_attrs(self) -> None:
@@ -98,7 +118,7 @@ class EvoChild(EvoEntity):
self._evo_id = evo_device.id
self._evo_tcs = evo_device.tcs
self._schedule: list[DayOfWeekDhwT] | None = None
self._schedule: list[EvoDayOfWeekDhwT] | None = None
self._setpoints: dict[str, Any] = {}
@property
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.2.0"]
"requirements": ["evohome-async==2.0.1"]
}
+37 -28
View File
@@ -1,19 +1,25 @@
"""Service handlers for the Evohome integration."""
from datetime import timedelta
import re
from typing import Any, Final
from evohomeasync2 import ControlSystem
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schemas.const import (
S2_DURATION as SZ_DURATION,
S2_PERIOD as SZ_PERIOD,
from evohomeasync2.const import (
SZ_CAN_BE_TEMPORARY,
SZ_DURATION,
SZ_MODE,
SZ_PERIOD,
SZ_SETPOINT,
SZ_STATE,
SZ_SYSTEM_MODE,
SZ_TIMING_MODE,
)
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
@@ -25,9 +31,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from .const import (
ATTR_DURATION,
ATTR_PERIOD,
ATTR_SETPOINT,
DOMAIN,
REFRESH_BREAKS_IN_HA_VERSION,
RESET_BREAKS_IN_HA_VERSION,
@@ -37,15 +40,21 @@ from .const import (
from .coordinator import EvoDataUpdateCoordinator
from .helpers import async_create_deprecation_issue_once
def _as_snake_case(mode: str) -> str:
"""Convert a CamelCase string to the snake_case used by the library."""
return re.sub(r"(?<!^)(?=[A-Z])", "_", mode).lower()
# System service schemas (registered as domain services)
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# unsupported modes are rejected at runtime with ServiceValidationError
vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
vol.Required(SZ_MODE): cv.string, # ... so, don't use SystemMode enum here
vol.Exclusive(SZ_DURATION, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All(
vol.Exclusive(SZ_PERIOD, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
@@ -54,10 +63,8 @@ SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# Zone service schemas (registered as entity services)
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION): vol.All(
vol.Required(SZ_SETPOINT): vol.All(vol.Coerce(float), vol.Range(min=4.0, max=35.0)),
vol.Optional(SZ_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
@@ -65,8 +72,8 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# DHW service schemas (registered as entity services)
SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_STATE): cv.boolean,
vol.Optional(ATTR_DURATION): vol.All(
vol.Required(SZ_STATE): cv.boolean,
vol.Optional(SZ_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
@@ -154,45 +161,45 @@ def _register_dhw_entity_services(hass: HomeAssistant) -> None:
def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None:
"""Validate that a set_system_mode service call is properly formed."""
mode = data[ATTR_MODE]
tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes}
mode = data[SZ_MODE]
tcs_modes = {m[SZ_SYSTEM_MODE].value: m for m in tcs.allowed_system_modes}
# Validation occurs here, instead of in the library, because it uses a slightly
# different schema (until instead of duration/period) for the method invoked
# via this service call
if (mode_info := tcs_modes.get(mode)) is None:
if (mode_info := tcs_modes.get(_as_snake_case(mode))) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_not_supported",
translation_placeholders={ATTR_MODE: mode},
translation_placeholders={SZ_MODE: mode},
)
# voluptuous schema ensures that duration and period are not both present
if not mode_info[SZ_CAN_BE_TEMPORARY]:
if ATTR_DURATION in data or ATTR_PERIOD in data:
if SZ_DURATION in data or SZ_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_be_temporary",
translation_placeholders={ATTR_MODE: mode},
translation_placeholders={SZ_MODE: mode},
)
return
timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary
if timing_mode == SZ_DURATION and ATTR_PERIOD in data:
if timing_mode == SZ_DURATION and SZ_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_period",
translation_placeholders={ATTR_MODE: mode},
translation_placeholders={SZ_MODE: mode},
)
if timing_mode == SZ_PERIOD and ATTR_DURATION in data:
if timing_mode == SZ_PERIOD and SZ_DURATION in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_duration",
translation_placeholders={ATTR_MODE: mode},
translation_placeholders={SZ_MODE: mode},
)
@@ -243,7 +250,9 @@ def setup_service_functions(
payload = {
"unique_id": unique_id,
"service": call.service,
"data": call.data,
"data": {**call.data, SZ_MODE: _as_snake_case(call.data[SZ_MODE])}
if SZ_MODE in call.data
else call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
@@ -5,8 +5,12 @@ import logging
from typing import Any, override
import evohomeasync2 as evo
from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS
from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode
from evohomeasync2.const import (
SZ_STATE_STATUS,
SZ_TEMPERATURE_STATUS,
DhwState as EvoDhwState,
ZoneMode as EvoZoneMode,
)
from homeassistant.components.water_heater import (
WaterHeaterEntity,
+38
View File
@@ -0,0 +1,38 @@
"""LLM tools for the fan integration."""
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from . import DOMAIN
from .intent import INTENT_FAN_SET_SPEED
# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_FAN_SET_SPEED,)
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return LLM tools for the integration's intents when its domain is exposed."""
if api_id != LLM_API_ASSIST:
return None
if not llm_context.assistant:
return None
if not any(
async_should_expose(hass, llm_context.assistant, state.entity_id)
for state in hass.states.async_all(DOMAIN)
):
return None
tools: list[Tool] = [
IntentTool(handler.intent_type, handler)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
return LLMTools(tools=tools)
+1 -1
View File
@@ -1 +1 @@
"""The fints component."""
"""The FinTS integration."""
+1 -1
View File
@@ -1 +1 @@
"""The flock component."""
"""The Flock integration."""
+1 -1
View File
@@ -1 +1 @@
"""The foobot component."""
"""The Foobot integration."""
@@ -98,6 +98,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
use_tls=self._use_tls,
timeout=60.0,
pool_maxsize=30,
redact_debug_log=True,
)
except FRITZ_AUTH_EXCEPTIONS:
return ERROR_AUTH_INVALID
@@ -215,6 +215,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
use_tls=self.use_tls,
timeout=60.0,
pool_maxsize=30,
redact_debug_log=True,
)
if not self.connection:
@@ -245,7 +246,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
{
**vars(info),
"NewDeviceLog": "***omitted***",
"device_log": "***omitted***",
"NewSerialNumber": "***omitted***",
"serial_number": "***omitted***",
},
)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260624.3"]
"requirements": ["home-assistant-frontend==20260624.4"]
}
@@ -23,6 +23,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN
from .const import GeolocationEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
@@ -44,7 +45,10 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
def source_match(state: State | None, source: str) -> bool:
"""Check if the state matches the provided source."""
return state is not None and state.attributes.get("source") == source
return (
state is not None
and state.attributes.get(GeolocationEntityStateAttribute.SOURCE) == source
)
async def async_attach_trigger(
@@ -1 +1 @@
"""The gitlab_ci component."""
"""The GitLab-CI integration."""
@@ -1 +1 @@
"""The haveibeenpwned component."""
"""The HaveIBeenPwned integration."""
@@ -26,6 +26,12 @@ INPUT_EVENT_VALUES = {
InputEventValues.LONG_PRESS: "long_press",
}
DOORBELL_EVENT_VALUES = {
InputEventValues.SINGLE_PRESS: "ring",
InputEventValues.DOUBLE_PRESS: "double_press",
InputEventValues.LONG_PRESS: "long_press",
}
class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity):
"""Representation of a Homekit event entity."""
@@ -50,11 +56,17 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity):
self.entity_description = entity_description
self._event_values = (
DOORBELL_EVENT_VALUES
if entity_description.device_class == EventDeviceClass.DOORBELL
else INPUT_EVENT_VALUES
)
# An INPUT_EVENT may support single_press, long_press and
# double_press. All are optional. So we have to clamp
# InputEventValues for this exact device
self._attr_event_types = [
INPUT_EVENT_VALUES[v]
self._event_values[v]
for v in clamp_enum_to_char(InputEventValues, self._char)
]
@@ -82,7 +94,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity):
# pollable, but always returns None when polled
# Make sure we don't explode if we see that edge case.
return
self._trigger_event(INPUT_EVENT_VALUES[self._char.value])
self._trigger_event(self._event_values[self._char.value])
self.async_write_ha_state()
@@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HomematicIP Cloud component."""
"""Set up the HomematicIP Cloud integration."""
accesspoints = config.get(DOMAIN, [])
for conf in accesspoints:
@@ -1,4 +1,4 @@
"""Config flow to configure the HomematicIP Cloud component."""
"""Config flow to configure the HomematicIP Cloud integration."""
from collections.abc import Mapping
from typing import Any, override
@@ -12,7 +12,7 @@ from .hap import HomematicipAuth
class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for the HomematicIP Cloud component."""
"""Config flow for the HomematicIP Cloud integration."""
VERSION = 2
@@ -1,4 +1,4 @@
"""Constants for the HomematicIP Cloud component."""
"""Constants for the HomematicIP Cloud integration."""
import logging
@@ -1,4 +1,4 @@
"""Generic entity for the HomematicIP Cloud component."""
"""Generic entity for the HomematicIP Cloud integration."""
import contextlib
import logging
@@ -1,4 +1,4 @@
"""Errors for the HomematicIP Cloud component."""
"""Errors for the HomematicIP Cloud integration."""
from homeassistant.exceptions import HomeAssistantError
@@ -1,4 +1,4 @@
"""Access point for the HomematicIP Cloud component."""
"""Access point for the HomematicIP Cloud integration."""
import asyncio
from collections.abc import Callable
@@ -139,7 +139,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
@override
def available(self) -> bool:
"""Return True if shade position data is available."""
return super().available and self.positions.primary is not None
return super().available and self._is_position_available
@property
def _is_position_available(self) -> bool:
"""Return if the cover contains positional data."""
return self.positions.primary is not None
@property
@override
@@ -567,9 +572,9 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase):
@property
@override
def available(self) -> bool:
"""Return True if shade position data is available."""
return super().available and self.positions.tilt is not None
def _is_position_available(self) -> bool:
"""Return if the cover contains positional data."""
return self.positions.tilt is not None
class PowerViewShadeTopDown(PowerViewShadeBase):
@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
"requirements": ["aioimmich==0.15.1"]
"requirements": ["aioimmich==0.16.0"]
}
@@ -49,6 +49,14 @@ class InfraredEmitterEntity(RestoreEntity):
__last_command_sent: str | None = None
@override
def _default_to_device_class_name(self) -> bool:
"""Return True if an unnamed entity should be named by its device class.
For infrared emitters this is True if the entity has a device class.
"""
return self.device_class is not None
@property
@final
@override
@@ -101,6 +109,14 @@ class InfraredReceiverEntity(RestoreEntity):
__last_signal_received: str | None = None
@override
def _default_to_device_class_name(self) -> bool:
"""Return True if an unnamed entity should be named by its device class.
For infrared receivers this is True if the entity has a device class.
"""
return self.device_class is not None
@cached_property
def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]:
"""Subscriber callback set, lazily initialized on first access."""
@@ -1,6 +1,6 @@
{
"entity_component": {
"_": {
"emitter": {
"name": "Infrared emitter"
},
"receiver": {
@@ -0,0 +1,51 @@
"""LLM tools for the intent_script integration."""
import slugify as unicode_slug
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from . import ScriptIntentHandler
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return an LLM tool for each configured intent script."""
if api_id != LLM_API_ASSIST:
return None
handlers = [
handler
for handler in intent.async_get(hass)
if isinstance(handler, ScriptIntentHandler)
]
exposed_domains = {
state.domain
for state in hass.states.async_all()
if async_should_expose(hass, llm_context.assistant, state.entity_id)
}
handlers = [
handler
for handler in handlers
if handler.platforms is None or handler.platforms & exposed_domains
]
if not handlers:
return None
# Intent script names come from user configuration, so slugify them into
# valid tool names.
tools: list[Tool] = [
IntentTool(
unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False),
handler,
)
for handler in handlers
]
return LLMTools(tools=tools)
@@ -1,4 +1,4 @@
"""Config flow to configure the IQVIA component."""
"""Config flow to configure the IQVIA integration."""
from typing import Any, override
-4
View File
@@ -165,10 +165,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
new_data = {**entry.data}
new_options = {**entry.options}
+84 -14
View File
@@ -1,19 +1,24 @@
"""Support for KNX button entities."""
from typing import override
from typing import Any, override
from xknx.devices import RawValue as XknxRawValue
from xknx.devices import ExposeSensor as XknxExposeSensor, RawValue as XknxRawValue
from homeassistant import config_entries
from homeassistant.components.button import ButtonEntity
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .knx_module import KNXModule
from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -21,27 +26,60 @@ async def async_setup_entry(
config_entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the KNX binary sensor platform."""
"""Set up button(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.BUTTON,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiButton,
),
)
async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config)
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.BUTTON):
entities.extend(
KnxYamlButton(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON):
entities.extend(
KnxUiButton(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class KNXButton(KnxYamlEntity, ButtonEntity):
class _KnxButton(ButtonEntity):
"""Representation of a KNX button."""
_device: XknxRawValue | XknxExposeSensor
_payload: Any
@override
async def async_press(self) -> None:
"""Press the button."""
await self._device.set(self._payload)
class KnxYamlButton(_KnxButton, KnxYamlEntity):
"""Representation of a KNX button configured via YAML."""
_device: XknxRawValue
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
# dpt-value to payload conversion is done in schema validation for yaml config
self._payload = config[CONF_PAYLOAD]
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
)
self._payload = config[CONF_PAYLOAD]
super().__init__(
knx_module=knx_module,
unique_id=f"{self._device.remote_value.group_address}_{self._payload}",
@@ -49,7 +87,39 @@ class KNXButton(KnxYamlEntity, ButtonEntity):
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
@override
async def async_press(self) -> None:
"""Press the button."""
await self._device.set(self._payload)
class KnxUiButton(_KnxButton, KnxUiEntity):
"""Representation of a KNX button configured via the UI."""
_device: XknxRawValue | XknxExposeSensor
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize a KNX button."""
knx_conf = ConfigExtractor(config[DOMAIN])
button_data = knx_conf.get(CONF_DATA)
if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data:
self._payload = int(button_data[CONF_PAYLOAD], 16)
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
payload_length=button_data[CONF_PAYLOAD_LENGTH],
group_address=knx_conf.get_write(CONF_GA_SEND),
)
else:
dpt_string = knx_conf.get_dpt(CONF_GA_SEND)
self._payload = button_data[CONF_VALUE]
self._device = XknxExposeSensor(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
value_type=dpt_string,
group_address=knx_conf.get_write(CONF_GA_SEND),
respond_to_read=False,
)
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
+2
View File
@@ -26,6 +26,7 @@ KNX_ADDRESS: Final = "address"
CONF_INVERT: Final = "invert"
CONF_KNX_EXPOSE: Final = "expose"
CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address"
CONF_VALUE: Final = "value"
##
# Connection constants
@@ -178,6 +179,7 @@ SUPPORTED_PLATFORMS_YAML: Final = {
SUPPORTED_PLATFORMS_UI: Final = {
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
+42 -4
View File
@@ -2,9 +2,9 @@
from collections.abc import Mapping
from functools import cache
from typing import Literal, TypedDict
from typing import Literal, NotRequired, TypedDict, cast
from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
from xknx.dpt import DPTBase, DPTComplex, DPTComplexFieldSchema, DPTEnum, DPTNumeric
from xknx.dpt.dpt_16 import DPTString
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
@@ -24,15 +24,28 @@ class DPTInfo(TypedDict):
sensor_device_class: SensorDeviceClass | None
sensor_state_class: SensorStateClass | None
payload_length: int
# numeric specific
min: NotRequired[float]
max: NotRequired[float]
step: NotRequired[float]
# enum specific
options: NotRequired[list[str]]
# complex specific
schema: NotRequired[list[DPTComplexFieldSchema]]
@cache
def get_supported_dpts() -> Mapping[str, DPTInfo]:
"""Return a mapping of supported DPTs with HA specific attributes."""
dpts = {}
dpts: dict[str, DPTInfo] = {}
for dpt_class in DPTBase.dpt_class_tree():
dpt_number_str = dpt_class.dpt_number_str()
ha_dpt_class = _ha_dpt_class(dpt_class)
dpts[dpt_number_str] = DPTInfo(
info = DPTInfo(
dpt_class=ha_dpt_class,
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
sub=dpt_class.dpt_sub_number,
@@ -40,7 +53,15 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]:
unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit),
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
payload_length=dpt_class.payload_length,
)
if ha_dpt_class == "numeric":
_add_numeric_details(info, cast(type[DPTNumeric], dpt_class))
elif ha_dpt_class == "enum":
_add_enum_details(info, cast(type[DPTEnum], dpt_class))
elif ha_dpt_class == "complex":
_add_complex_details(info, cast(type[DPTComplex], dpt_class))
dpts[dpt_number_str] = info
return dpts
@@ -57,6 +78,23 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
raise ValueError("Unsupported DPT class")
def _add_numeric_details(dpt_info: DPTInfo, dpt_cls: type[DPTNumeric]) -> None:
"""Add numeric specific details to the DPTInfo."""
dpt_info["min"] = dpt_cls.value_min
dpt_info["max"] = dpt_cls.value_max
dpt_info["step"] = dpt_cls.resolution
def _add_enum_details(dpt_info: DPTInfo, dpt_cls: type[DPTEnum]) -> None:
"""Add enum specific details to the DPTInfo."""
dpt_info["options"] = [o.name.lower() for o in dpt_cls.get_valid_values()]
def _add_complex_details(dpt_info: DPTInfo, dpt_cls: type[DPTComplex]) -> None:
"""Add complex specific details to the DPTInfo."""
dpt_info["schema"] = dpt_cls.get_dict_schema()
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
"7.011": SensorDeviceClass.DISTANCE,
"7.012": SensorDeviceClass.CURRENT,
+6 -4
View File
@@ -57,6 +57,7 @@ from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
CONF_VALUE,
KNX_ADDRESS,
ClimateConf,
ColorTempModes,
@@ -98,9 +99,12 @@ def _max_payload_value(payload_length: int) -> int:
def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict:
"""Validate a button entity payload configuration."""
"""Validate a button entity payload configuration.
Returns raw payload and length from value and type (DPT), if given.
"""
if _type := entity_config.get(CONF_TYPE):
_payload = entity_config[ButtonSchema.CONF_VALUE]
_payload = entity_config[CONF_VALUE]
if (transcoder := DPTBase.parse_transcoder(_type)) is None:
raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.")
entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length
@@ -234,8 +238,6 @@ class ButtonSchema(KNXPlatformSchema):
PLATFORM = Platform.BUTTON
CONF_VALUE = "value"
payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`"
length_or_type_msg = (
f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`"
@@ -19,6 +19,9 @@ CONF_GA_TIME: Final = "ga_time"
CONF_GA_STEP: Final = "ga_step"
# Button
CONF_GA_SEND: Final = "ga_send"
# Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
@@ -3,7 +3,8 @@
from enum import StrEnum, unique
import voluptuous as vol
from xknx.dpt import DPTNumeric
from xknx.dpt import DPTBase, DPTBinary, DPTNumeric
from xknx.exceptions import ConversionError
from homeassistant.components.climate import HVACMode
from homeassistant.components.number import (
@@ -36,9 +37,11 @@ from ..const import (
CONF_CONTEXT_TIMEOUT,
CONF_IGNORE_INTERNAL_STATE,
CONF_INVERT,
CONF_PAYLOAD_LENGTH,
CONF_RESET_AFTER,
CONF_RESPOND_TO_READ,
CONF_SYNC_STATE,
CONF_VALUE,
DOMAIN,
SUPPORTED_PLATFORMS_UI,
ClimateConf,
@@ -92,6 +95,7 @@ from .const import (
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_SCENE,
CONF_GA_SEND,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_SPEED,
@@ -115,6 +119,7 @@ from .knx_selector import (
GASelector,
GroupSelect,
GroupSelectOption,
KnxPayloadSelector,
KNXSectionFlat,
SyncStateSelector,
)
@@ -169,6 +174,55 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema(
},
)
def _button_data_sub_validator(config: dict) -> dict:
"""Validate data matching configured DPT."""
dpt = config[CONF_GA_SEND].get(CONF_DPT)
transcoder = None
if dpt:
transcoder = DPTBase.parse_transcoder(dpt)
assert transcoder is not None # already checked by GASelector
if CONF_VALUE in config[CONF_DATA]:
try:
transcoder.to_knx(config[CONF_DATA][CONF_VALUE])
except ConversionError as ex:
raise vol.Invalid(
f"Value invalid for DPT {transcoder.dpt_number_str()}",
path=([CONF_DATA]),
) from ex
elif CONF_PAYLOAD_LENGTH in config[CONF_DATA]:
length = config[CONF_DATA][CONF_PAYLOAD_LENGTH]
if length != transcoder.payload_length or (
length != 0 and transcoder.payload_type is DPTBinary
):
raise vol.Invalid(
f"Payload length invalid for DPT {transcoder.dpt_number_str()}",
path=([CONF_DATA]),
)
return config
# without DPT only raw allowed -> payload + payload_length (checked by KnxPayloadSelector)
if CONF_PAYLOAD_LENGTH in config[CONF_DATA]:
return config
raise vol.Invalid("Invalid configuration for button entity")
BUTTON_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Required(CONF_GA_SEND): GASelector(
state=False,
write_required=True,
passive=False,
dpt=["numeric", "enum", "complex", "string"],
dpt_required=False, # for raw payload support
),
vol.Required(CONF_DATA): KnxPayloadSelector(ga_path=CONF_GA_SEND),
},
),
_button_data_sub_validator,
)
COVER_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
@@ -741,6 +795,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst(
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
Platform.BUTTON: BUTTON_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
Platform.COVER: COVER_KNX_SCHEMA,
Platform.DATE: DATE_KNX_SCHEMA,
@@ -6,6 +6,9 @@ from typing import Any, override
import voluptuous as vol
from homeassistant.const import CONF_PAYLOAD
from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE
from ..dpt import HaDptClass, get_supported_dpts
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
@@ -159,7 +162,11 @@ class GroupSelect(KNXSelectorBase):
class GASelector(KNXSelectorBase):
"""Selector for a KNX group address structure."""
"""Selector for a KNX group address structure.
`dpt_required` optional dpt only apply to dpt-class lists, enums are always required.
`valid_dpt` is used in frontend to filter dropdown menu - no validation is done.
"""
selector_type = "knx_group_address"
@@ -171,6 +178,7 @@ class GASelector(KNXSelectorBase):
write_required: bool = False,
state_required: bool = False,
dpt: type[Enum] | list[HaDptClass] | None = None,
dpt_required: bool = True,
valid_dpt: str | Iterable[str] | None = None,
) -> None:
"""Initialize the group address selector."""
@@ -180,7 +188,7 @@ class GASelector(KNXSelectorBase):
self.write_required = write_required
self.state_required = state_required
self.dpt = dpt
# valid_dpt is used in frontend to filter dropdown menu - no validation is done
self.dpt_required = dpt_required
self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt
self.schema = self.build_schema()
@@ -196,6 +204,7 @@ class GASelector(KNXSelectorBase):
}
if self.dpt is not None:
if isinstance(self.dpt, list):
# optional / required is not passed to FE - only validated in BE
options["dptClasses"] = self.dpt
else:
options["dptSelect"] = [
@@ -267,7 +276,8 @@ class GASelector(KNXSelectorBase):
"""Add DPT validator to the schema."""
if self.dpt is not None:
if isinstance(self.dpt, list):
schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
marker = vol.Required if self.dpt_required else vol.Optional
schema[marker(CONF_DPT)] = vol.In(get_supported_dpts())
else:
schema[vol.Required(CONF_DPT)] = vol.In(
{item.value for item in self.dpt}
@@ -300,3 +310,64 @@ class SyncStateSelector(KNXSelectorBase):
if not self.allow_false and not data:
raise vol.Invalid(f"Sync state cannot be {data}")
return self.schema(data)
class KnxPayloadSelector(KNXSelectorBase):
"""Selector for KNX payload configuration.
Raw payloads are stored as hex strings.
"""
schema = vol.Any(
{
vol.Required(CONF_VALUE): object,
},
{
vol.Required(CONF_PAYLOAD): str,
vol.Required(CONF_PAYLOAD_LENGTH): vol.All(int, vol.Range(min=0, max=14)),
},
)
selector_type = "knx_payload"
def __init__(self, ga_path: str) -> None:
"""Initialize the KNX payload selector."""
self.ga_path = ga_path
@override
def serialize(self) -> dict[str, Any]:
"""Serialize the selector to a dictionary."""
return {
"type": self.selector_type,
"ga_path": self.ga_path,
}
@override
def __call__(self, data: Any) -> Any:
"""Validate the passed data."""
validated = self.schema(data)
if CONF_PAYLOAD in validated and CONF_PAYLOAD_LENGTH in validated:
payload = validated[CONF_PAYLOAD]
payload_length = validated[CONF_PAYLOAD_LENGTH]
try:
int_payload = int(payload, 16)
except ValueError as ex:
raise vol.Invalid(f"Invalid payload format: {payload}") from ex
validated[CONF_PAYLOAD] = hex(int_payload) # prepends "0x" if not present
if int_payload < 0:
raise vol.Invalid(f"Payload cannot be negative: {payload}")
if payload_length == 0:
# DPT 1,2,3 is marked length 0, has 6 bit size
if int_payload > 63:
raise vol.Invalid(
f"Payload exceeds DPT 1,2,3 limit of 0x3f (63): {payload}"
)
else:
max_payload = (1 << (payload_length * 8)) - 1
if int_payload > max_payload:
raise vol.Invalid(
f"Payload {payload} exceeds possible maximum for "
f"length {payload_length}: {hex(max_payload)}"
)
# CONF_VALUE branch needs subvalidator as we don't have the DPT available here
return validated
+26
View File
@@ -453,6 +453,19 @@
}
}
},
"button": {
"description": "Entity for sending predefined values.",
"knx": {
"data": {
"description": "The value sent when the button is pressed. The format of the value depends on the DPT of the configured address.",
"label": "Data"
},
"ga_send": {
"description": "Group address the value is sent to.",
"label": "Address"
}
}
},
"climate": {
"description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.",
"knx": {
@@ -1014,6 +1027,19 @@
"project": {
"description": "Inspect imported group addresses",
"title": "Project"
},
"selectors": {
"knx-payload-selector": {
"dpt_missing": "No DPT selected Typed mode not available",
"mode": {
"label": "Payload format",
"raw": "Raw payload",
"typed": "Typed value"
},
"raw_length": "Payload length",
"raw_length_description": "Length of the raw payload in bytes. For DPT 1, 2 and 3 use `0`.",
"raw_payload": "Raw payload"
}
}
},
"device_automation": {
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.2.5"]
"requirements": ["pylamarzocco==2.4.2"]
}
@@ -0,0 +1,38 @@
"""LLM tools for the lawn_mower integration."""
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from .const import DOMAIN
from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING
# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING)
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return LLM tools for the integration's intents when its domain is exposed."""
if api_id != LLM_API_ASSIST:
return None
if not llm_context.assistant:
return None
if not any(
async_should_expose(hass, llm_context.assistant, state.entity_id)
for state in hass.states.async_all(DOMAIN)
):
return None
tools: list[Tool] = [
IntentTool(handler.intent_type, handler)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
return LLMTools(tools=tools)
+38
View File
@@ -0,0 +1,38 @@
"""LLM tools for the light integration."""
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from .const import DOMAIN
from .intent import INTENT_SET
# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_SET,)
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return LLM tools for the integration's intents when its domain is exposed."""
if api_id != LLM_API_ASSIST:
return None
if not llm_context.assistant:
return None
if not any(
async_should_expose(hass, llm_context.assistant, state.entity_id)
for state in hass.states.async_all(DOMAIN)
):
return None
tools: list[Tool] = [
IntentTool(handler.intent_type, handler)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
return LLMTools(tools=tools)
@@ -0,0 +1,60 @@
"""The llama.cpp integration."""
import logging
import openai
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from .api import async_create_client, async_list_models
_LOGGER = logging.getLogger(__name__)
PLATFORMS = (Platform.CONVERSATION,)
type LlamaCppConfigEntry = ConfigEntry[openai.AsyncOpenAI]
async def async_setup_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool:
"""Set up llama.cpp from a config entry."""
client = await async_create_client(hass, entry.data)
# Validate the connection by listing models
try:
await async_list_models(client)
except HomeAssistantError as err:
if err.translation_key == "invalid_auth":
raise ConfigEntryAuthFailed(
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
) from err
raise ConfigEntryNotReady(
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
) from err
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool:
"""Unload llama.cpp."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
+192
View File
@@ -0,0 +1,192 @@
"""API client helper for llama.cpp integration.
This module contains thin wrappers around the OpenAI completions APIs used
to simplify Home Assistant integration and configuration. It handles client
setup, model validation, and API error handling.
"""
from collections.abc import Generator, Mapping
from contextlib import contextmanager
import logging
from typing import Any, cast
import openai
from openai._streaming import AsyncStream
from openai.types.chat import (
ChatCompletionChunk,
ChatCompletionMessageParam,
ChatCompletionToolParam,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.httpx_client import get_async_client
from .const import (
CONF_BASE_URL,
DEFAULT_API_KEY,
DEFAULT_MODEL,
DOMAIN,
RECOMMENDED_CHAT_MODELS,
)
_LOGGER = logging.getLogger(__name__)
# Simple prompt to test model basic chat completion capability. We send tools
# to ensure the model and server correctly supports tool calling. We set a
# minimal max_tokens to consume few resources.
_TEST_MESSAGES: list[ChatCompletionMessageParam] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
]
_TEST_TOOLS: list[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": "test_function",
"description": "Test function.",
"parameters": {"type": "object", "properties": {}},
},
}
]
_TEST_MAX_TOKENS = 3
async def async_create_client(
hass: HomeAssistant, config_entry_data: Mapping[str, Any]
) -> openai.AsyncOpenAI:
"""Create a new OpenAI client."""
api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY
client = openai.AsyncOpenAI(
api_key=api_key,
base_url=config_entry_data[CONF_BASE_URL],
http_client=get_async_client(hass),
)
# Cache current platform data which gets added to each request
# (caching done by library)
_ = await hass.async_add_executor_job(client.platform_headers)
return client
async def async_list_models(client: openai.AsyncOpenAI) -> list[str]:
"""Return a list of models supported by the client."""
with api_error_handler():
page = await client.with_options(timeout=10.0).models.list()
return [model.id async for model in page]
async def async_validate_completions(
client: openai.AsyncOpenAI,
model: str,
stream: bool = False,
) -> None:
"""Validate that we can speak to the model over the completions API."""
with api_error_handler():
result = await client.chat.completions.create(
model=model,
messages=_TEST_MESSAGES,
tools=_TEST_TOOLS,
max_tokens=_TEST_MAX_TOKENS,
stream=stream,
)
if stream:
stream_result = cast(AsyncStream[ChatCompletionChunk], result)
async for event in stream_result:
if not event.choices:
continue
if event.choices[0].finish_reason is not None:
continue
def recommended_model(models: list[str] | None) -> str:
"""Return the selected model from user input."""
if not models:
return DEFAULT_MODEL
for model in RECOMMENDED_CHAT_MODELS:
if model in models:
return model
return models[0]
def model_name_to_title(model_id: str) -> str:
"""Convert a model ID into a human-readable title (inverse slugification).
Examples:
- "deepseek-v4-flash" -> "Deepseek V4 Flash"
- "gpt-4" -> "Gpt 4"
- "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct"
- "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5"
"""
words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split()
return " ".join(word.capitalize() for word in words)
def _extract_error_message(err: openai.APIStatusError) -> str:
"""Extract a clean error message from an APIStatusError response or message."""
error_message = ""
if err.response is not None:
try:
json_data = err.response.json()
if isinstance(json_data, dict) and "error" in json_data:
error_message = json_data["error"].get("message") or ""
except ValueError:
pass
return error_message or err.message or str(err)
@contextmanager
def api_error_handler() -> Generator[None]:
"""Context manager to handle API errors and translate them to HomeAssistantErrors."""
try:
yield
except openai.APITimeoutError as err:
_LOGGER.error("Timeout talking to API: %s", err)
error_message = err.message or str(err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout",
translation_placeholders={"message": error_message},
) from err
except openai.APIConnectionError as err:
_LOGGER.error("Connection error talking to API: %s", err)
error_message = err.message or str(err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"message": error_message},
) from err
except openai.AuthenticationError as err:
_LOGGER.error("Authentication error talking to API: %s", err)
error_message = _extract_error_message(err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"message": error_message},
) from err
except openai.APIStatusError as err:
_LOGGER.error("Status error talking to API: %s", err)
error_message = _extract_error_message(err)
if err.status_code == 402:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="quota_exceeded",
translation_placeholders={"message": error_message},
) from err
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": error_message},
) from err
except openai.OpenAIError as err:
_LOGGER.error("Generic error talking to API: %s", err)
error_message = getattr(err, "message", None) or str(err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": error_message},
) from err
@@ -0,0 +1,383 @@
"""Config flow for llama.cpp integration."""
import logging
from typing import Any, cast, override
import openai
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TemplateSelector,
)
from .api import (
async_create_client,
async_list_models,
async_validate_completions,
model_name_to_title,
recommended_model,
)
from .const import (
CONF_BASE_URL,
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_RECOMMENDED,
CONF_STREAMING,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_BASE_URL,
DOMAIN,
LOGGER,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str,
vol.Optional(CONF_API_KEY): str,
}
)
class LlamaCppConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for llama.cpp."""
VERSION = 1
data: dict[str, Any] | None = None
client: openai.AsyncOpenAI | None = None
models: list[str] | None = None
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
try:
self.client = await async_create_client(self.hass, user_input)
self.models = await async_list_models(self.client)
except HomeAssistantError as err:
LOGGER.error("Connection validation failed: %s", err)
errors["base"] = err.translation_key or "unknown"
except Exception: # pylint: disable=broad-except # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.data = user_input
return await self.async_step_model()
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_model(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle selecting a model."""
assert self.client is not None
assert self.models is not None
assert self.data is not None
errors = {}
if user_input is not None:
model = user_input[CONF_CHAT_MODEL]
try:
await async_validate_completions(
self.client,
model=model,
stream=False,
)
except HomeAssistantError as err:
LOGGER.error("Model completion validation failed: %s", err)
errors["base"] = err.translation_key or "unknown"
else:
stream_support = True
try:
await async_validate_completions(
self.client,
model=model,
stream=True,
)
except HomeAssistantError:
stream_support = False
base_options = {
**user_input,
}
return self.async_create_entry(
title=self.data[CONF_BASE_URL],
data={
**self.data,
CONF_STREAMING: stream_support,
},
subentries=[
{
"subentry_type": "conversation",
"data": {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
**base_options,
},
"title": model_name_to_title(model),
"unique_id": None,
},
],
)
return self.async_show_form(
step_id="model",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Optional(
CONF_CHAT_MODEL,
): SelectSelector(
SelectSelectorConfig(
options=self.models,
translation_key=CONF_CHAT_MODEL,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
),
),
}
),
{
CONF_CHAT_MODEL: (user_input or {}).get(
CONF_CHAT_MODEL, recommended_model(self.models)
),
},
),
errors=errors,
)
@classmethod
@callback
@override
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {
"conversation": ConversationSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
last_rendered_recommended = False
options: dict[str, Any] | None = None
models: list[str] | None = None
@property
def _openai_client(self) -> openai.AsyncOpenAI:
"""Return the OpenAI client."""
return cast(openai.AsyncOpenAI, self._get_entry().runtime_data)
async def _get_models(self) -> list[str] | None:
"""Return the list of models."""
if self.models is None:
self.models = await async_list_models(self._openai_client)
return self.models
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a subentry."""
if self._get_entry().state is not ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
try:
models = await self._get_models()
except HomeAssistantError:
return self.async_abort(reason="cannot_connect")
self.options = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_CHAT_MODEL: recommended_model(models),
}
self.last_rendered_recommended = cast(
bool, self.options.get(CONF_RECOMMENDED, False)
)
return await self.async_step_init()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of a subentry."""
return await self.async_step_init()
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage initial options."""
# abort if entry is not loaded
if self._get_entry().state is not ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if self.options is None:
self.options = self._get_reconfigure_subentry().data.copy()
self.last_rendered_recommended = cast(
bool, self.options.get(CONF_RECOMMENDED, False)
)
try:
models = await self._get_models()
except HomeAssistantError:
return self.async_abort(reason="cannot_connect")
options = self.options
if user_input is not None:
model = user_input[CONF_CHAT_MODEL]
try:
await async_validate_completions(
self._openai_client,
model=model,
stream=self._get_entry().data.get(CONF_STREAMING, False),
)
except HomeAssistantError as err:
LOGGER.error("Model completion validation failed: %s", err)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
llama_cpp_config_option_schema(self.hass, options, models)
),
user_input,
),
errors={"base": err.translation_key or "unknown"},
)
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if self.source == "user":
return self.async_create_entry(
title=model_name_to_title(user_input[CONF_CHAT_MODEL]),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
title=model_name_to_title(user_input[CONF_CHAT_MODEL]),
)
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API, []),
}
schema = llama_cpp_config_option_schema(self.hass, options, models)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), options
),
)
def llama_cpp_config_option_schema(
hass: HomeAssistant,
options: dict[str, Any],
models: list[str] | None = None,
) -> dict:
"""Return a schema for llama.cpp completion options."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
]
LOGGER.debug("Available LLM APIs: %s", hass_apis)
schema: dict[vol.Required | vol.Optional, Any] = {}
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
}
)
schema.update(
{
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=options.get(CONF_CHAT_MODEL, recommended_model(models)),
): SelectSelector(
SelectSelectorConfig(
options=models or [],
translation_key=CONF_CHAT_MODEL,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
),
),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
}
)
if options.get(CONF_RECOMMENDED):
return schema
schema.update(
{
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TEMPERATURE,
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
}
)
return schema
@@ -0,0 +1,30 @@
"""Constants for the llama.cpp integration."""
import logging
DOMAIN = "llama_cpp"
LOGGER = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "llama.cpp Conversation"
CONF_CHAT_MODEL = "chat_model"
CONF_MAX_TOKENS = "max_tokens"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
CONF_BASE_URL = "base_url"
CONF_RECOMMENDED = "recommended"
CONF_STREAMING = "streaming"
# Some servers set placeholder model names which we can use as a default
DEFAULT_MODEL = "gpt-3.5-turbo"
RECOMMENDED_CHAT_MODELS = [
DEFAULT_MODEL,
"gpt-4",
"local-model",
]
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_TEMPERATURE = 0.7
RECOMMENDED_TOP_P = 1.0
DEFAULT_BASE_URL = "http://localhost:8080/v1"
DEFAULT_API_KEY = "sk-0000000000000000000"
@@ -0,0 +1,83 @@
"""Conversation support for llama.cpp."""
from typing import Literal, override
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LlamaCppConfigEntry
from .const import DOMAIN
from .entity import LlamaCppBaseLLMEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LlamaCppConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
for subentry in config_entry.subentries.values():
async_add_entities(
[LlamaCppConversationEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class LlamaCppConversationEntity(
conversation.ConversationEntity,
conversation.AbstractConversationAgent,
LlamaCppBaseLLMEntity,
):
"""llama.cpp conversation agent."""
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent."""
super().__init__(entry, subentry)
if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
@property
@override
def supported_languages(self) -> list[str] | Literal["*"]:
"""Return a list of supported languages."""
return MATCH_ALL
@override
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
conversation.async_set_agent(self.hass, self.entry, self)
@override
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
conversation.async_unset_agent(self.hass, self.entry)
await super().async_will_remove_from_hass()
@override
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Process a sentence."""
options = self.subentry.data
try:
await chat_log.async_provide_llm_data(
user_input.as_llm_context(DOMAIN),
options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT),
user_input.extra_system_prompt,
)
except conversation.ConverseError as err:
return err.as_conversation_result()
await self._async_handle_chat_log(chat_log)
return conversation.async_get_result_from_chat_log(user_input, chat_log)
@@ -0,0 +1,457 @@
"""Base entity for llama.cpp Conversation."""
import base64
from collections.abc import AsyncGenerator, Callable
import json
import logging
import mimetypes
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from openai import AsyncOpenAI
from openai._streaming import AsyncStream
from openai._types import Omit
from openai.types.chat import (
ChatCompletion,
ChatCompletionAssistantMessageParam,
ChatCompletionChunk,
ChatCompletionContentPartParam,
ChatCompletionContentPartTextParam,
ChatCompletionFunctionToolParam,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam,
ChatCompletionMessageToolCallParam,
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from .api import api_error_handler
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_STREAMING,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_MODEL,
DOMAIN,
LOGGER,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
)
if TYPE_CHECKING:
from . import LlamaCppConfigEntry
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
_LOGGER = logging.getLogger(__name__)
def _format_structured_output(
name: str, structure: vol.Schema, llm_api: llm.APIInstance | None
) -> ResponseFormatJSONSchema:
"""Format structured output specification."""
schema = convert(
structure, custom_serializer=llm_api.custom_serializer if llm_api else None
)
return ResponseFormatJSONSchema(
type="json_schema",
json_schema={
"name": name,
"strict": True,
"schema": cast(dict[str, object], schema),
},
)
def _format_tool(
tool: llm.Tool,
custom_serializer: Callable[[Any], Any] | None,
) -> ChatCompletionFunctionToolParam:
"""Format tool specification."""
tool_spec = FunctionDefinition(
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
)
if tool.description:
tool_spec["description"] = tool.description
return ChatCompletionFunctionToolParam(type="function", function=tool_spec)
def _convert_content_to_chat_message(
content: conversation.Content,
) -> ChatCompletionMessageParam | None:
"""Convert any native chat message for this agent to the native format."""
_LOGGER.debug("_convert_content_to_chat_message=%s", content)
if isinstance(content, conversation.ToolResultContent):
return ChatCompletionToolMessageParam(
role="tool",
tool_call_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
role: Literal["user", "assistant", "system"] = content.role
if role == "system" and content.content:
return ChatCompletionSystemMessageParam(role="system", content=content.content)
if role == "user" and content.content:
return ChatCompletionUserMessageParam(role="user", content=content.content)
if role == "assistant":
param = ChatCompletionAssistantMessageParam(
role="assistant",
content=content.content,
)
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
param["tool_calls"] = [
ChatCompletionMessageToolCallParam(
type="function",
id=tool_call.id,
function=Function(
arguments=json.dumps(tool_call.tool_args),
name=tool_call.tool_name,
),
)
for tool_call in content.tool_calls
]
return param
LOGGER.warning("Could not convert message to OpenAI API: %s", content)
return None
def _decode_tool_arguments(arguments: str) -> Any:
"""Decode tool call arguments."""
try:
return json.loads(arguments)
except json.JSONDecodeError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="json_parse_error",
translation_placeholders={"message": str(err)},
) from err
async def _transform_response(
message: ChatCompletionMessage,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the OpenAI API message to a ChatLog format."""
data: conversation.AssistantContentDeltaDict = {
"role": message.role,
"content": message.content,
}
if message.tool_calls:
data["tool_calls"] = [
llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.function.name,
tool_args=_decode_tool_arguments(tool_call.function.arguments),
)
for tool_call in message.tool_calls
if isinstance(tool_call, ChatCompletionMessageFunctionToolCall)
]
yield data
def _convert_content_to_param(
content: conversation.Content,
) -> ChatCompletionMessageParam:
"""Convert any native chat message for this agent to the native format."""
if isinstance(content, conversation.ToolResultContent):
return ChatCompletionToolMessageParam(
role="tool",
tool_call_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not isinstance(content, conversation.AssistantContent) or not content.tool_calls:
if isinstance(content, conversation.SystemContent):
return ChatCompletionSystemMessageParam(
role="system",
content=content.content or "",
)
return cast(
ChatCompletionMessageParam,
{"role": content.role, "content": content.content or ""},
)
return ChatCompletionAssistantMessageParam(
role="assistant",
content=content.content,
tool_calls=[
ChatCompletionMessageToolCallParam(
id=tool_call.id,
function=Function(
arguments=json.dumps(tool_call.tool_args),
name=tool_call.tool_name,
),
type="function",
)
for tool_call in content.tool_calls
],
)
async def _transform_stream(
result: AsyncStream[ChatCompletionChunk],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
current_tool_call: dict[str, Any] | None = None
yielded_role = False
async for chunk in result:
LOGGER.debug("Received chunk: %s", chunk)
if not chunk.choices:
continue
choice = chunk.choices[0]
if choice.finish_reason:
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call["id"],
tool_name=current_tool_call["tool_name"],
tool_args=_decode_tool_arguments(
current_tool_call["tool_args"]
)
if current_tool_call["tool_args"]
else {},
)
]
}
break
delta = choice.delta
if current_tool_call is None and not delta.tool_calls:
yield_dict: conversation.AssistantContentDeltaDict = {}
if not yielded_role and delta.role == "assistant":
yield_dict["role"] = "assistant"
yielded_role = True
if delta.content is not None:
yield_dict["content"] = delta.content
if yield_dict:
yield yield_dict
continue
if (
not delta.tool_calls
or not (delta_tool_call := delta.tool_calls[0])
or not delta_tool_call.function
):
continue
if current_tool_call and delta_tool_call.index == current_tool_call["index"]:
current_tool_call["tool_args"] += delta_tool_call.function.arguments or ""
continue
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call["id"],
tool_name=current_tool_call["tool_name"],
tool_args=_decode_tool_arguments(
current_tool_call["tool_args"]
),
)
]
}
current_tool_call = {
"index": delta_tool_call.index,
"id": delta_tool_call.id,
"tool_name": delta_tool_call.function.name,
"tool_args": delta_tool_call.function.arguments or "",
}
class LlamaCppBaseLLMEntity(Entity):
"""llama.cpp base LLM entity."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="llama.cpp",
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL),
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
tools: list[ChatCompletionFunctionToolParam] | 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
]
model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL)
messages = [
m
for content in chat_log.content
if (m := _convert_content_to_chat_message(content))
]
response_format: ResponseFormatJSONSchema | Omit = Omit()
if structure and structure_name:
response_format = _format_structured_output(
structure_name, structure, chat_log.llm_api
)
last_content = chat_log.content[-1]
if (
isinstance(last_content, conversation.UserContent)
and last_content.attachments
):
files = await async_prepare_files_for_prompt(
self.hass,
[a.path for a in last_content.attachments],
)
for i in range(len(messages) - 1, -1, -1):
if messages[i]["role"] == "user":
user_msg = cast(ChatCompletionUserMessageParam, messages[i])
current_content = user_msg.get("content")
if isinstance(current_content, str):
user_msg["content"] = [
ChatCompletionContentPartTextParam(
type="text", text=current_content
),
*files,
]
break
client: AsyncOpenAI = self.entry.runtime_data
streaming = bool(
self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False))
)
for _iteration in range(MAX_TOOL_ITERATIONS):
with api_error_handler():
result = await client.chat.completions.create(
messages=messages,
model=model,
tools=tools or Omit(),
response_format=response_format,
max_tokens=cast(
int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
),
top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)),
temperature=cast(
float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE)
),
user=chat_log.conversation_id,
stream=cast(Any, streaming),
)
convert_message: Callable[[Any], Any]
async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict]
if streaming:
convert_message = _convert_content_to_param
async_generator = _transform_stream(
cast(AsyncStream[ChatCompletionChunk], result)
)
else:
convert_message = _convert_content_to_chat_message
async_generator = _transform_response(
cast(ChatCompletion, result).choices[0].message
)
messages.extend(
[
msg
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, async_generator
)
if (msg := convert_message(content))
]
)
if not chat_log.unresponded_tool_results:
break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[Path]
) -> list[ChatCompletionContentPartParam]:
"""Prepare files for OpenAI-compatible API.
Caller needs to ensure that the files are allowed.
"""
def guess_file_type(file_path: Path) -> tuple[str | None, str | None]:
"""Guess the file type based on the file extension."""
return mimetypes.guess_type(str(file_path))
def append_files_to_content() -> list[ChatCompletionContentPartParam]:
content: list[ChatCompletionContentPartParam] = []
for file_path in files:
if not file_path.exists():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="file_not_found",
translation_placeholders={"file_path": str(file_path)},
)
mime_type, _ = guess_file_type(file_path)
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unsupported_file_type",
translation_placeholders={"file_path": str(file_path)},
)
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_file}",
"detail": "auto",
},
}
)
elif mime_type.startswith("application/pdf"):
content.append(
{
"type": "text",
"text": f"[File: {file_path.name}]\nContent: {base64_file}",
}
)
return content
return await hass.async_add_executor_job(append_files_to_content)
@@ -0,0 +1,13 @@
{
"domain": "llama_cpp",
"name": "llama.cpp",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@allenporter"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/llama_cpp",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["openai==2.21.0"]
}
@@ -0,0 +1,104 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No service actions are registered by this integration.
appropriate-polling:
status: exempt
comment: The integration does not poll and is push-based.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No service actions are registered by this integration.
docs-conditions:
status: exempt
comment: No custom conditions are supported by this integration.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: No custom triggers are supported by this integration.
entity-event-setup:
status: exempt
comment: No event entities or helper events are supported by this integration.
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:
status: exempt
comment: No service actions are registered by this integration.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: Conversation entities do not have an unavailable state.
integration-owner: done
log-when-unavailable:
status: exempt
comment: Conversation entities do not have an unavailable state.
parallel-updates:
status: exempt
comment: No periodic updates are performed by this integration.
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The integration does not support discovery.
discovery:
status: exempt
comment: The integration does not support discovery.
docs-data-update:
status: exempt
comment: No periodic data updates are performed by this integration.
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: The integration does not support physical devices.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: No physical devices are supported.
entity-category:
status: exempt
comment: Conversation entity does not require an entity category.
entity-device-class:
status: exempt
comment: Conversation entity does not require a device class.
entity-disabled-by-default:
status: exempt
comment: Conversation entity should be enabled by default.
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No icons are defined for this integration.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repair issues are defined for this integration.
stale-devices:
status: exempt
comment: No physical devices are supported.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -0,0 +1,98 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"api_error": "[%key:common::config_flow::error::unknown%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"quota_exceeded": "Your account or API key has insufficient credits.",
"timeout": "Connection timed out.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"model": {
"data": {
"chat_model": "[%key:common::generic::model%]"
},
"data_description": {
"chat_model": "Select the model to use."
}
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"base_url": "URL"
},
"data_description": {
"api_key": "API key for the server (optional).",
"base_url": "Base URL of your running OpenAI-compatible server (e.g. http://localhost:8080/v1)."
}
}
}
},
"config_subentries": {
"conversation": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"entry_type": "Conversation agent",
"initiate_flow": {
"user": "Add conversation agent"
},
"step": {
"init": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"llm_hass_api": "Control Home Assistant",
"max_tokens": "Maximum tokens to return in response",
"name": "[%key:common::config_flow::data::name%]",
"prompt": "Instructions",
"recommended": "Recommended model settings",
"temperature": "Temperature",
"top_p": "Top P"
},
"data_description": {
"chat_model": "Select the model to use.",
"llm_hass_api": "Select the level of control over Home Assistant.",
"max_tokens": "Select the maximum number of tokens to return.",
"prompt": "Instruct how the LLM should respond. This can be a template.",
"recommended": "Select whether to use recommended model settings.",
"temperature": "Select the temperature for response variability.",
"top_p": "Select the top P value for response diversity."
}
}
}
}
},
"exceptions": {
"api_error": {
"message": "API error: {message}."
},
"cannot_connect": {
"message": "Cannot connect to the server: {message}."
},
"file_not_found": {
"message": "File does not exist: {file_path}."
},
"invalid_auth": {
"message": "Invalid authentication: {message}."
},
"json_parse_error": {
"message": "Unexpected tool argument response: {message}."
},
"quota_exceeded": {
"message": "Your account or API key has insufficient credits: {message}."
},
"timeout": {
"message": "Connection timed out: {message}."
},
"unsupported_file_type": {
"message": "Only images and PDF are supported by the OpenAI API, {file_path} is not an image file or PDF."
}
}
}
+88
View File
@@ -0,0 +1,88 @@
"""The LLM integration.
Owns the LLM tools platform: integrations contribute tools to the LLM APIs
through an ``<integration>/llm.py`` platform with an ``async_get_tools`` hook.
The platforms are loaded lazily and queried per request. The framework
(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``.
"""
from dataclasses import dataclass
import logging
from typing import Protocol
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms
from homeassistant.helpers.llm import LLMContext, Tool
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DATA_PLATFORMS: HassKey[LazyIntegrationPlatforms[LLMToolsPlatformProtocol]] = HassKey(
"llm_platforms"
)
@dataclass(slots=True)
class LLMTools:
"""Tools and an optional prompt fragment contributed by a platform."""
tools: list[Tool]
prompt: str | None = None
class LLMToolsPlatformProtocol(Protocol):
"""Define the format that LLM tools platforms can have."""
@callback
def async_get_tools(
self, hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return the integration's LLM tools for the given context and API.
Return None when the integration has nothing for the given API.
"""
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LLM integration."""
hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms(
hass, DOMAIN, _process_llm_tools_platform
)
return True
@callback
def _process_llm_tools_platform(
hass: HomeAssistant, domain: str, platform: LLMToolsPlatformProtocol
) -> LLMToolsPlatformProtocol:
"""Process an integration's LLM tools platform."""
return platform
async def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools:
"""Return the tools and merged prompt from all integration platforms."""
platforms = await hass.data[DATA_PLATFORMS].async_get_platforms()
tools: list[Tool] = []
prompts: list[str] = []
# Sort by domain so the tool and prompt order is independent of load order.
for domain, platform in sorted(platforms.items()):
try:
result = platform.async_get_tools(hass, llm_context, api_id)
except Exception:
_LOGGER.exception("Error getting tools from LLM platform %s", domain)
continue
if result is None:
continue
tools.extend(result.tools)
if result.prompt:
prompts.append(result.prompt)
return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None)
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the LLM integration."""
DOMAIN = "llm"
+45
View File
@@ -0,0 +1,45 @@
"""LLM tools provided by the llm integration."""
from typing import override
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.llm import LLMContext, Tool, ToolInput
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType
from . import LLMTools
class GetDateTimeTool(Tool):
"""Tool for getting the current date and time."""
name = "GetDateTime"
description = "Provides the current date and time."
@override
async def async_call(
self,
hass: HomeAssistant,
tool_input: ToolInput,
llm_context: LLMContext,
) -> JsonObjectType:
"""Get the current date and time."""
now = dt_util.now()
return {
"success": True,
"result": {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"timezone": now.strftime("%Z"),
"weekday": now.strftime("%A"),
},
}
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools:
"""Return the always-available LLM tools."""
return LLMTools(tools=[GetDateTimeTool()])
@@ -0,0 +1,9 @@
{
"domain": "llm",
"name": "LLM",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/llm",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal"
}
+36
View File
@@ -0,0 +1,36 @@
"""Authentication helper classes for the Model Context Protocol integration."""
from dataclasses import dataclass
import re
import httpx
from yarl import URL
# Headers and regex for WWW-Authenticate parsing for rfc9728
WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
SCOPES_REGEXP = r'scope="([^"]+)"'
@dataclass
class AuthenticateHeader:
"""Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
resource_metadata_url: str
scopes: list[str] | None = None
@classmethod
def from_header(
cls, url: str, error_response: httpx.Response
) -> AuthenticateHeader | None:
"""Create AuthenticateHeader from WWW-Authenticate header."""
if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
match := re.search(RESOURCE_METADATA_REGEXP, header)
):
return None
resource_metadata_url = str(URL(url).join(URL(match.group(1))))
scope_match = re.search(SCOPES_REGEXP, header)
return cls(
resource_metadata_url=resource_metadata_url,
scopes=scope_match.group(1).split(" ") if scope_match else None,
)
+10 -29
View File
@@ -4,7 +4,6 @@ import asyncio
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
import logging
import re
from typing import Any, cast, override
import httpx
@@ -24,6 +23,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .auth import AuthenticateHeader
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
@@ -35,35 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
# Headers and regex for WWW-Authenticate parsing for rfc9728
WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource"
SCOPES_REGEXP = r'scope="([^"]+)"'
@dataclass
class AuthenticateHeader:
"""Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
resource_metadata_url: str
scopes: list[str] | None = None
@classmethod
def from_header(
cls, url: str, error_response: httpx.Response
) -> AuthenticateHeader | None:
"""Create AuthenticateHeader from WWW-Authenticate header."""
if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
match := re.search(RESOURCE_METADATA_REGEXP, header)
):
return None
resource_metadata_url = str(URL(url).join(URL(match.group(1))))
scope_match = re.search(SCOPES_REGEXP, header)
return cls(
resource_metadata_url=resource_metadata_url,
scopes=scope_match.group(1).split(" ") if scope_match else None,
)
@dataclass
@@ -369,6 +341,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
if entry_data and "auth_header" in entry_data:
self.auth_header = entry_data["auth_header"]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -379,6 +353,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
config_entry = self._get_reauth_entry()
self.data = {**config_entry.data}
if "auth_implementation" not in self.data:
# For entries configured without authentication (no-auth), any authentication
# failure (from a tool call or coordinator update) requires upgrading to OAuth.
# We bypass validate_input connection handshake (which might succeed if the server
# doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery.
return await self.async_step_auth_discovery()
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
self.hass, config_entry
)
+42 -2
View File
@@ -18,12 +18,17 @@ from voluptuous_openapi import convert_to_voluptuous
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import llm
from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.json import JsonObjectType
from .auth import AuthenticateHeader
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -98,6 +103,7 @@ class ModelContextProtocolTool(llm.Tool):
description: str | None,
parameters: vol.Schema,
server_url: str,
config_entry: ConfigEntry,
token_manager: TokenManager | None = None,
) -> None:
"""Initialize the tool."""
@@ -105,6 +111,7 @@ class ModelContextProtocolTool(llm.Tool):
self.description = description
self.parameters = parameters
self.server_url = server_url
self.config_entry = config_entry
self.token_manager = token_manager
@override
@@ -126,9 +133,32 @@ class ModelContextProtocolTool(llm.Tool):
except TimeoutError as error:
_LOGGER.debug("Timeout when calling tool: %s", error)
raise HomeAssistantError(f"Timeout when calling tool: {error}") from error
except OAuth2TokenRequestReauthError as error:
_LOGGER.debug("OAuth token request failed when calling tool: %s", error)
self.config_entry.async_start_reauth(hass)
raise ConfigEntryAuthFailed(
"OAuth token request failed when calling tool"
) from error
except httpx.HTTPStatusError as error:
_LOGGER.debug("Error when calling tool: %s", error)
if error.response.status_code == 401:
auth_header = AuthenticateHeader.from_header(
self.server_url, error.response
)
self.config_entry.async_start_reauth(
hass, data={"auth_header": auth_header}
)
raise ConfigEntryAuthFailed(
"The MCP server requires authentication"
) from error
raise HomeAssistantError(f"Error when calling tool: {error}") from error
except httpx.HTTPError as error:
_LOGGER.debug(
"Error communicating with MCP server when calling tool: %s", error
)
raise HomeAssistantError(
f"Error communicating with MCP server when calling tool: {error}"
) from error
return result.model_dump(exclude_unset=True, exclude_none=True)
@@ -169,9 +199,18 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
except TimeoutError as error:
_LOGGER.debug("Timeout when listing tools: %s", error)
raise UpdateFailed(f"Timeout when listing tools: {error}") from error
except OAuth2TokenRequestReauthError as error:
_LOGGER.debug("OAuth token request failed: %s", error)
raise ConfigEntryAuthFailed("OAuth token request failed") from error
except httpx.HTTPStatusError as error:
_LOGGER.debug("Error communicating with API: %s", error)
if error.response.status_code == 401 and self.token_manager is not None:
if error.response.status_code == 401:
auth_header = AuthenticateHeader.from_header(
self.config_entry.data[CONF_URL], error.response
)
self.config_entry.async_start_reauth(
self.hass, data={"auth_header": auth_header}
)
raise ConfigEntryAuthFailed(
"The MCP server requires authentication"
) from error
@@ -195,6 +234,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
tool.description,
parameters,
self.config_entry.data[CONF_URL],
self.config_entry,
self.token_manager,
)
)
+89 -55
View File
@@ -3,12 +3,16 @@
This registers HTTP endpoints that support the Streamable HTTP protocol as
well as the older SSE as a transport layer.
The Streamable HTTP protocol uses a single HTTP endpoint:
The Streamable HTTP protocol uses these HTTP endpoints:
- /api/mcp_server: The Streamable HTTP endpoint currently implements the
- /api/mcp: The Streamable HTTP endpoint currently implements the
stateless protocol for simplicity. This receives client requests and
sends them to the MCP server, then waits for a response to send back to
the client.
the client. This serves the configured LLM APIs and does not require
admin access.
- /api/mcp/<API ID>: The same Streamable HTTP endpoint, but exposing a
specific LLM API selected by its ID. These endpoints require admin access,
except for the Assist API.
The older SSE protocol has two HTTP endpoints:
@@ -43,6 +47,7 @@ from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import llm
from .const import DOMAIN
@@ -67,6 +72,7 @@ def async_register(hass: HomeAssistant) -> None:
hass.http.register_view(ModelContextProtocolSSEView())
hass.http.register_view(ModelContextProtocolMessagesView())
hass.http.register_view(ModelContextProtocolStreamableView())
hass.http.register_view(ModelContextProtocolStreamableApiView())
def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
@@ -127,7 +133,7 @@ async def create_streams() -> AsyncGenerator[Streams]:
async def create_mcp_server(
hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry
hass: HomeAssistant, context: Context, llm_api_id: str | list[str]
) -> tuple[Server, InitializationOptions]:
"""Initialize the MCP server to ensure it's ready to handle requests."""
llm_context = llm.LLMContext(
@@ -137,7 +143,6 @@ async def create_mcp_server(
assistant=conversation.DOMAIN,
device_id=None,
)
llm_api_id = entry.data[CONF_LLM_HASS_API]
server = await create_server(hass, llm_api_id, llm_context)
options = await hass.async_add_executor_job(
server.create_initialization_options # Reads package for version info
@@ -165,7 +170,9 @@ class ModelContextProtocolSSEView(HomeAssistantView):
entry = async_get_config_entry(hass)
session_manager = entry.runtime_data
server, options = await create_mcp_server(hass, self.context(request), entry)
server, options = await create_mcp_server(
hass, self.context(request), entry.data[CONF_LLM_HASS_API]
)
async with (
create_streams() as streams,
@@ -231,65 +238,92 @@ class ModelContextProtocolMessagesView(HomeAssistantView):
return web.Response(status=200)
async def _async_handle_streamable_message(
request: web.Request, context: Context, llm_api_id: str | list[str]
) -> web.StreamResponse:
"""Process a single JSON-RPC message for the given LLM API."""
hass = request.app[KEY_HASS]
# The request must include a JSON-RPC message
if CONTENT_TYPE_JSON not in request.headers.get("accept", ""):
raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}")
if request.content_type != CONTENT_TYPE_JSON:
raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}")
try:
json_data = await request.json()
message = types.JSONRPCMessage.model_validate(json_data)
except ValueError as err:
_LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err)
raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err
_LOGGER.debug("Received client message: %s", message)
# For notifications and responses only, return 202 Accepted
if not isinstance(message.root, JSONRPCRequest):
_LOGGER.debug("Notification or response received, returning 202")
return web.Response(status=HTTPStatus.ACCEPTED)
# The MCP server runs as a background task for the duration of the
# request. We open a buffered stream pair to communicate with it. The
# request is sent to the MCP server and we wait for a single response
# then shut down the server.
server, options = await create_mcp_server(hass, context, llm_api_id)
async with create_streams() as streams:
async def run_server() -> None:
await server.run(
streams.read_stream, streams.write_stream, options, stateless=True
)
async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg:
tg.start_soon(run_server)
await streams.read_stream_writer.send(SessionMessage(message))
session_message = await anext(streams.write_stream_reader)
tg.cancel_scope.cancel()
_LOGGER.debug("Sending response: %s", session_message)
return web.json_response(
data=session_message.message.model_dump(by_alias=True, exclude_none=True),
)
class ModelContextProtocolStreamableView(HomeAssistantView):
"""Model Context Protocol Streamable HTTP endpoint."""
"""Model Context Protocol Streamable HTTP endpoint.
This serves the configured LLM APIs and does not require admin access.
"""
name = f"{DOMAIN}:streamable"
url = STREAMABLE_API
async def get(self, request: web.Request) -> web.StreamResponse:
"""Handle unsupported methods."""
return web.Response(
status=HTTPStatus.METHOD_NOT_ALLOWED, text="Only POST method is supported"
)
async def post(self, request: web.Request) -> web.StreamResponse:
"""Process JSON-RPC messages for the Model Context Protocol."""
"""Process JSON-RPC messages for the configured LLM APIs."""
hass = request.app[KEY_HASS]
entry = async_get_config_entry(hass)
return await _async_handle_streamable_message(
request, self.context(request), entry.data[CONF_LLM_HASS_API]
)
# The request must include a JSON-RPC message
if CONTENT_TYPE_JSON not in request.headers.get("accept", ""):
raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}")
if request.content_type != CONTENT_TYPE_JSON:
raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}")
try:
json_data = await request.json()
message = types.JSONRPCMessage.model_validate(json_data)
except ValueError as err:
_LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err)
raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err
_LOGGER.debug("Received client message: %s", message)
class ModelContextProtocolStreamableApiView(HomeAssistantView):
"""Model Context Protocol Streamable HTTP endpoint for a specific LLM API.
# For notifications and responses only, return 202 Accepted
if not isinstance(message.root, JSONRPCRequest):
_LOGGER.debug("Notification or response received, returning 202")
return web.Response(status=HTTPStatus.ACCEPTED)
The LLM API is selected by its ID in the URL. These endpoints require
admin access, except for the Assist API.
"""
# The MCP server runs as a background task for the duration of the
# request. We open a buffered stream pair to communicate with it. The
# request is sent to the MCP server and we wait for a single response
# then shut down the server.
server, options = await create_mcp_server(hass, self.context(request), entry)
name = f"{DOMAIN}:streamable_api"
url = f"{STREAMABLE_API}/{{api_id}}"
async with create_streams() as streams:
async def run_server() -> None:
await server.run(
streams.read_stream, streams.write_stream, options, stateless=True
)
async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg:
tg.start_soon(run_server)
await streams.read_stream_writer.send(SessionMessage(message))
session_message = await anext(streams.write_stream_reader)
tg.cancel_scope.cancel()
_LOGGER.debug("Sending response: %s", session_message)
return web.json_response(
data=session_message.message.model_dump(
by_alias=True, exclude_none=True
),
)
async def post(self, request: web.Request, api_id: str) -> web.StreamResponse:
"""Process JSON-RPC messages for the LLM API identified by api_id."""
hass = request.app[KEY_HASS]
if api_id != llm.LLM_API_ASSIST and not request["hass_user"].is_admin:
raise Unauthorized
if api_id not in {api.id for api in llm.async_get_apis(hass)}:
raise HTTPNotFound(text=f"Unknown LLM API '{api_id}'")
return await _async_handle_streamable_message(
request, self.context(request), api_id
)
@@ -0,0 +1,58 @@
"""LLM tools for the media_player integration."""
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from .const import (
DOMAIN,
INTENT_MEDIA_NEXT,
INTENT_MEDIA_PAUSE,
INTENT_MEDIA_PREVIOUS,
INTENT_MEDIA_SEARCH_AND_PLAY,
INTENT_MEDIA_UNPAUSE,
INTENT_PLAYER_MUTE,
INTENT_PLAYER_UNMUTE,
INTENT_SET_VOLUME,
INTENT_SET_VOLUME_RELATIVE,
)
# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (
INTENT_MEDIA_NEXT,
INTENT_MEDIA_PAUSE,
INTENT_PLAYER_MUTE,
INTENT_PLAYER_UNMUTE,
INTENT_MEDIA_PREVIOUS,
INTENT_MEDIA_SEARCH_AND_PLAY,
INTENT_MEDIA_UNPAUSE,
INTENT_SET_VOLUME,
INTENT_SET_VOLUME_RELATIVE,
)
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""Return LLM tools for the integration's intents when its domain is exposed."""
if api_id != LLM_API_ASSIST:
return None
if not llm_context.assistant:
return None
if not any(
async_should_expose(hass, llm_context.assistant, state.entity_id)
for state in hass.states.async_all(DOMAIN)
):
return None
tools: list[Tool] = [
IntentTool(handler.intent_type, handler)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
return LLMTools(tools=tools)
@@ -20,7 +20,7 @@ from .const import (
URI_SCHEME_REGEX,
)
from .error import MediaSourceError, Unresolvable
from .helper import async_browse_media, async_resolve_media
from .helper import async_browse_media, async_resolve_media, async_search_media
from .models import (
BrowseMediaSource,
MediaSource,
@@ -42,6 +42,7 @@ __all__ = [
"Unresolvable",
"async_browse_media",
"async_resolve_media",
"async_search_media",
"generate_media_source_id",
"is_media_source_id",
]
@@ -2,7 +2,12 @@
from collections.abc import Callable
from homeassistant.components.media_player import BrowseError, BrowseMedia
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
SearchMedia,
SearchMediaQuery,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
@@ -67,6 +72,34 @@ async def async_browse_media(
return item
async def async_search_media(
hass: HomeAssistant,
media_content_id: str | None,
query: SearchMediaQuery,
) -> SearchMedia:
"""Return media searched in the media source."""
if DOMAIN not in hass.data:
raise BrowseError("Media Source not loaded")
try:
return await _get_media_item(hass, media_content_id, None).async_search(query)
except NotImplementedError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="search_not_supported",
translation_placeholders={"media_content_id": str(media_content_id)},
) from err
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="search_media_failed",
translation_placeholders={
"media_content_id": str(media_content_id),
"error": str(err),
},
) from err
async def async_resolve_media(
hass: HomeAssistant,
media_content_id: str,
+35 -1
View File
@@ -7,20 +7,25 @@ import voluptuous as vol
from homeassistant.components import frontend, websocket_api
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_SEARCH_QUERY,
CONTENT_AUTH_EXPIRY_TIME,
BrowseError,
MediaClass,
SearchMediaQuery,
async_process_play_media_url,
)
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant
from .error import Unresolvable
from .helper import async_browse_media, async_resolve_media
from .helper import async_browse_media, async_resolve_media, async_search_media
def async_setup(hass: HomeAssistant) -> None:
"""Set up the HTTP views and WebSocket commands for media sources."""
websocket_api.async_register_command(hass, websocket_browse_media)
websocket_api.async_register_command(hass, websocket_search_media)
websocket_api.async_register_command(hass, websocket_resolve_media)
frontend.async_register_built_in_panel(
hass, "media-browser", "media_browser", "mdi:play-box-multiple"
@@ -48,6 +53,35 @@ async def websocket_browse_media(
connection.send_error(msg["id"], "browse_media_failed", str(err))
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/search_media",
vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str,
vol.Required(ATTR_MEDIA_SEARCH_QUERY): str,
vol.Optional(ATTR_MEDIA_FILTER_CLASSES): [vol.Coerce(MediaClass)],
}
)
@websocket_api.async_response
async def websocket_search_media(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Search available media."""
try:
result = await async_search_media(
hass,
msg["media_content_id"],
SearchMediaQuery(
search_query=msg[ATTR_MEDIA_SEARCH_QUERY],
media_filter_classes=msg.get(ATTR_MEDIA_FILTER_CLASSES),
),
)
except BrowseError as err:
connection.send_error(msg["id"], "search_media_failed", str(err))
return
connection.send_result(msg["id"], result.as_dict())
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/resolve_media",
@@ -13,7 +13,13 @@ import voluptuous as vol
from homeassistant.components import http, websocket_api
from homeassistant.components.http import require_admin
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
SearchMedia,
SearchMediaQuery,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
@@ -23,6 +29,7 @@ from .error import Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
MAX_UPLOAD_SIZE = 1024 * 1024 * 20
MAX_SEARCH_RESULTS = 100
LOGGER = logging.getLogger(__name__)
@@ -175,6 +182,72 @@ class LocalSource(MediaSource):
self._browse_media, source_dir_id, location
)
@override
async def async_search_media(
self, item: MediaSourceItem, query: SearchMediaQuery
) -> SearchMedia:
"""Search media by file name within the local media directories."""
if item.identifier:
try:
source_dir_id, location = self.async_parse_identifier(item)
except Unresolvable as err:
raise BrowseError(str(err)) from err
search_dirs = [(source_dir_id, location)]
else:
search_dirs = [(source_dir_id, "") for source_dir_id in self.media_dirs]
return await self.hass.async_add_executor_job(
self._search_media, search_dirs, query
)
def _search_media(
self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery
) -> SearchMedia:
"""Search media files by name (runs in the executor)."""
query_str = query.search_query.casefold()
filter_classes = set(query.media_filter_classes or ())
results: list[BrowseMedia] = []
for source_dir_id, location in search_dirs:
if len(results) >= MAX_SEARCH_RESULTS:
break
base_path = Path(self.media_dirs[source_dir_id])
search_path = base_path / location
if not search_path.is_dir():
continue
# Traverse lazily so MAX_SEARCH_RESULTS can short-circuit large libraries
for path in search_path.rglob("*"):
if len(results) >= MAX_SEARCH_RESULTS:
break
relative = path.relative_to(base_path)
if any(part.startswith(".") for part in relative.parts):
continue
if query_str not in path.name.casefold() or not path.is_file():
continue
mime_type, _ = mimetypes.guess_type(str(path))
if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
continue
media_class = MEDIA_CLASS_MAP.get(
mime_type.split("/")[0], MediaClass.DIRECTORY
)
if filter_classes and media_class not in filter_classes:
continue
results.append(
BrowseMediaSource(
domain=self.domain,
identifier=f"{source_dir_id}/{relative}",
media_class=media_class,
media_content_type=mime_type,
title=path.name,
can_play=True,
can_expand=False,
)
)
results.sort(key=lambda item: item.title)
return SearchMedia(result=results)
def _browse_media(
self, source_dir_id: str | None, location: str
) -> BrowseMediaSource:
@@ -197,6 +270,7 @@ class LocalSource(MediaSource):
title=self.name,
can_play=False,
can_expand=True,
can_search=True,
children_media_class=MediaClass.DIRECTORY,
)
@@ -255,6 +329,7 @@ class LocalSource(MediaSource):
title=title,
can_play=is_file,
can_expand=is_dir,
can_search=is_dir,
)
if is_file or is_child:

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