mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
Merge branch 'dev' into gj-20260704-04
This commit is contained in:
@@ -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/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
"""The aurora component."""
|
||||
"""The Aurora integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -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)])
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
"""The fints component."""
|
||||
"""The FinTS integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The flock component."""
|
||||
"""The Flock integration."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the LLM integration."""
|
||||
|
||||
DOMAIN = "llm"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user