Compare commits

...

8 Commits

Author SHA1 Message Date
Robert Resch 61edf4b0eb Apply suggestion from @edenhaus 2026-05-27 16:06:48 +02:00
copilot-swe-agent[bot] 759429a9d9 Limit stale workflow app token permissions 2026-05-27 12:01:31 +00:00
renovate[bot] 8ca7ab7957 Update zizmor 2026-05-26 21:05:53 +00:00
HoffmanEl 66d4124439 Add quality scale cert expiry (#170491)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 22:42:38 +02:00
Copilot 99877d79e3 Replace duplicated ATTR_LOCATION with shared homeassistant.const import in hassio and remove unused ATTR_STATE mapping (#171334)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: agners <34061+agners@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-05-26 22:42:02 +02:00
Thomas D 978171b600 Use reported units for the Qbus integration (#171588) 2026-05-26 22:41:45 +02:00
Jonathan Segev 4bd011702e Add room priority select entity to Lyric integration (#167942) 2026-05-26 22:40:14 +02:00
Crocmagnon 64bc689bcf add ovhcloud_ai_endpoints integration (#171402) 2026-05-26 22:38:18 +02:00
37 changed files with 2225 additions and 41 deletions
+3 -2
View File
@@ -55,9 +55,10 @@ jobs:
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
permission-issues: write
permission-pull-requests: write
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
+1 -1
View File
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.25.2
hooks:
- id: zizmor
args:
+1
View File
@@ -428,6 +428,7 @@ homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.ovhcloud_ai_endpoints.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
Generated
+2
View File
@@ -1317,6 +1317,8 @@ CLAUDE.md @home-assistant/core
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -0,0 +1,81 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
config-flow: done
dependency-transparency:
status: exempt
comment: Integration has no external library dependencies.
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Config flow only collects host/port; the integration does not authenticate.
test-coverage:
status: todo
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
entity-category:
status: todo
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt
comment: Integration supports a single device per config entry.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -16,6 +16,10 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Reconfigure the certificate to test"
},
"user": {
@@ -24,6 +28,10 @@
"name": "The name of the certificate",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Define the certificate to test"
}
}
-17
View File
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_STATE = "state"
ATTR_STARTED = "started"
ATTR_URL = "url"
ATTR_REPOSITORY = "repository"
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
CONTAINER_STATS = "stats"
CONTAINER_INFO = "info"
# This is a mapping of which endpoint the key in the addon data
# is obtained from so we know which endpoint to update when the
# coordinator polls for updates.
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
ATTR_VERSION_LATEST: {CONTAINER_INFO},
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
ATTR_CPU_PERCENT: {CONTAINER_STATS},
ATTR_VERSION: {CONTAINER_INFO},
ATTR_STATE: {CONTAINER_INFO},
}
REQUEST_REFRESH_DELAY = 10
HELP_URLS = {
+1 -2
View File
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -43,7 +43,6 @@ from .const import (
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
+1 -1
View File
@@ -21,7 +21,7 @@ from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
@@ -1,5 +1,10 @@
{
"entity": {
"select": {
"room_priority": {
"default": "mdi:home-thermometer"
}
},
"sensor": {
"setpoint_status": {
"default": "mdi:thermostat"
+129
View File
@@ -0,0 +1,129 @@
"""Support for Honeywell Lyric select platform."""
import logging
from aiolyric.objects.device import LyricDevice
from aiolyric.objects.location import LyricLocation
from aiolyric.objects.priority import LyricRoom
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LYRIC_EXCEPTIONS
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .entity import LyricDeviceEntity
_LOGGER = logging.getLogger(__name__)
# Honeywell Lyric API priority types
PRIORITY_TYPE_PICK_A_ROOM = "PickARoom"
PRIORITY_TYPE_FOLLOW_ME = "FollowMe"
PRIORITY_TYPE_WHOLE_HOUSE = "WholeHouse"
# Option shown in the select for the FollowMe mode
OPTION_FOLLOW_ME = "follow_me"
async def async_setup_entry(
hass: HomeAssistant,
entry: LyricConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Honeywell Lyric select entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
LyricRoomPrioritySelect(coordinator, location, device)
for location in coordinator.data.locations
for device in location.devices
if device.device_class == "Thermostat"
and device.device_id.startswith("LCC")
and coordinator.data.rooms_dict.get(device.mac_id)
)
class LyricRoomPrioritySelect(LyricDeviceEntity, SelectEntity):
"""Select entity for Honeywell Lyric thermostat room priority."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "room_priority"
def __init__(
self,
coordinator: LyricDataUpdateCoordinator,
location: LyricLocation,
device: LyricDevice,
) -> None:
"""Initialize the room priority select entity."""
super().__init__(
coordinator,
location,
device,
f"{device.mac_id}_room_priority",
)
@property
def _rooms(self) -> dict[int, LyricRoom]:
"""Return the rooms for this thermostat."""
return self.coordinator.data.rooms_dict.get(self._mac_id, {})
@property
def options(self) -> list[str]:
"""Return the list of available room priority options."""
room_options = sorted(
room.room_name for room in self._rooms.values() if room.room_name
)
return [OPTION_FOLLOW_ME, *room_options]
@property
def current_option(self) -> str | None:
"""Return the currently selected room priority."""
priority = self.coordinator.data.priorities_dict.get(self._mac_id)
if priority is None:
return None
current = priority.current_priority
if current.priority_type == PRIORITY_TYPE_FOLLOW_ME:
return OPTION_FOLLOW_ME
if current.priority_type == PRIORITY_TYPE_PICK_A_ROOM:
selected = current.selected_rooms
if selected:
room = self._rooms.get(selected[0])
if room is not None:
return room.room_name
return None
async def async_select_option(self, option: str) -> None:
"""Set the room priority."""
if option == OPTION_FOLLOW_ME:
priority_type = PRIORITY_TYPE_FOLLOW_ME
rooms: list[int] = []
else:
priority_type = PRIORITY_TYPE_PICK_A_ROOM
room_id = next(
(rid for rid, room in self._rooms.items() if room.room_name == option),
None,
)
if room_id is None:
_LOGGER.error("Room not found: %s", option)
return
rooms = [room_id]
_LOGGER.debug("Set room priority: type=%s, rooms=%s", priority_type, rooms)
try:
await self.coordinator.data.update_priority(
self.location,
self.device,
priority_type=priority_type,
rooms=rooms,
)
except LYRIC_EXCEPTIONS as exception:
raise HomeAssistantError(
f"Failed to set room priority: {exception}"
) from exception
await self.coordinator.async_refresh()
@@ -37,6 +37,14 @@
}
},
"entity": {
"select": {
"room_priority": {
"name": "Room priority",
"state": {
"follow_me": "Follow me"
}
}
},
"sensor": {
"indoor_humidity": {
"name": "Indoor humidity"
@@ -0,0 +1,80 @@
"""The OVHcloud AI Endpoints integration."""
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
from openai.types.chat import ChatCompletionUserMessageParam
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from .const import BASE_URL
PLATFORMS = [Platform.CONVERSATION]
type OVHcloudAIEndpointsConfigEntry = ConfigEntry[AsyncOpenAI]
def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI:
"""Create the AsyncOpenAI client used by this integration."""
return AsyncOpenAI(
base_url=BASE_URL,
api_key=api_key,
http_client=get_async_client(hass),
)
async def _validate_api_key(client: AsyncOpenAI) -> None:
"""Validate the API key against the chat completions endpoint.
We send a chat completion request with an unknown ``extra_body`` field
to prevent valid usage and billing.
A valid key triggers a 400 (BadRequestError), which we treat as success.
An invalid key triggers a 401 (AuthenticationError),which propagates
along with any other exception.
"""
try:
await client.with_options(timeout=10.0).chat.completions.create(
model="llama@latest",
messages=[ChatCompletionUserMessageParam(role="user", content="ping")],
extra_body={"foo": "bar"},
)
except BadRequestError:
return
async def async_setup_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> bool:
"""Set up OVHcloud AI Endpoints from a config entry."""
client = _create_client(hass, entry.data[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
except OpenAIError as err:
raise ConfigEntryNotReady(err) from err
entry.runtime_data = client
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> None:
"""Reload the entry when its data or subentries change."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> bool:
"""Unload OVHcloud AI Endpoints."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,168 @@
"""Config flow for the OVHcloud AI Endpoints integration."""
import logging
from typing import Any
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
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_MODEL
from homeassistant.core import callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TemplateSelector,
)
from . import _create_client, _validate_api_key
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
_LOGGER = logging.getLogger(__name__)
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OVHcloud AI Endpoints."""
VERSION = 1
MINOR_VERSION = 1
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"conversation": ConversationFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="OVHcloud AI Endpoints",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
)
class ConversationFlowHandler(ConfigSubentryFlow):
"""Handle conversation subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.models: list[str] = []
self.options: dict[str, Any] = {}
async def _get_models(self) -> None:
"""Fetch models from OVHcloud AI Endpoints."""
client: AsyncOpenAI = self._get_entry().runtime_data
self.models = [
model.id async for model in client.with_options(timeout=10.0).models.list()
]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a conversation agent."""
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init(user_input)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage conversation agent configuration."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
return self.async_create_entry(
title=user_input[CONF_MODEL], data=user_input
)
try:
await self._get_models()
except OpenAIError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
options = [
SelectOptionDict(value=model_id, label=model_id) for model_id in self.models
]
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(self.hass)
]
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=True,
),
),
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": self.options.get(
CONF_PROMPT,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT],
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
default=self.options.get(
CONF_LLM_HASS_API,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API],
),
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
),
)
@@ -0,0 +1,16 @@
"""Constants for the OVHcloud AI Endpoints integration."""
import logging
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
from homeassistant.helpers import llm
DOMAIN = "ovhcloud_ai_endpoints"
LOGGER = logging.getLogger(__package__)
BASE_URL = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
@@ -0,0 +1,74 @@
"""Conversation support for OVHcloud AI Endpoints."""
from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import 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 OVHcloudAIEndpointsConfigEntry
from .const import DOMAIN
from .entity import OVHcloudAIEndpointsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OVHcloudAIEndpointsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
for subentry in config_entry.get_subentries_of_type("conversation"):
async_add_entities(
[OVHcloudAIEndpointsConversationEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class OVHcloudAIEndpointsConversationEntity(
OVHcloudAIEndpointsEntity, conversation.ConversationEntity
):
"""OVHcloud AI Endpoints conversation agent."""
_attr_name = None
def __init__(
self,
entry: OVHcloudAIEndpointsConfigEntry,
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
def supported_languages(self) -> list[str] | Literal["*"]:
"""Return a list of supported languages."""
return MATCH_ALL
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Process the user input and call the API."""
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,228 @@
"""Base entity for OVHcloud AI Endpoints."""
from collections.abc import AsyncGenerator, Callable
import json
import re
from typing import Any, Literal
import openai
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionFunctionToolParam,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCallParam,
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_MODEL
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from . import OVHcloudAIEndpointsConfigEntry
from .const import DOMAIN, LOGGER
MAX_TOOL_ITERATIONS = 10
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
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 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"] = [
ChatCompletionMessageFunctionToolCallParam(
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 Completions 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(f"Unexpected tool argument response: {err}") from err
def _split_thinking(content: str | None) -> tuple[str | None, str | None]:
"""Return (cleaned_content, thinking_content) extracted from ``<think>`` tags."""
if not content:
return content, None
thinking_parts = [m.group(1).strip() for m in _THINK_PATTERN.finditer(content)]
if not thinking_parts:
return content, None
cleaned = _THINK_PATTERN.sub("", content).strip() or None
thinking = "\n\n".join(part for part in thinking_parts if part) or None
return cleaned, thinking
def _extract_thinking(
message: ChatCompletionMessage,
) -> tuple[str | None, str | None]:
"""Return (cleaned_content, thinking_content) for an assistant message.
Priority order:
1. ``message.reasoning`` (OpenRouter, and vLLM >= 0.16.0 with a
``reasoning_parser`` configured, following OpenAI's recommendation
for gpt-oss).
2. ``message.reasoning_content`` (DeepSeek API, and vLLM < 0.16.0
with a ``reasoning_parser`` configured).
3. Inline ``<think>…</think>`` markup in ``message.content`` (any
reasoning model on vLLM without a ``reasoning_parser`` set).
"""
extras = message.model_extra or {}
for key in ("reasoning", "reasoning_content"):
value = extras.get(key)
if isinstance(value, str) and value.strip():
return message.content, value.strip()
return _split_thinking(message.content)
async def _transform_response(
message: ChatCompletionMessage,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the OVHcloud AI Endpoints message to a ChatLog format."""
cleaned_content, thinking_content = _extract_thinking(message)
data: conversation.AssistantContentDeltaDict = {
"role": message.role,
"content": cleaned_content,
}
if thinking_content:
data["thinking_content"] = thinking_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 tool_call.type == "function"
]
yield data
class OVHcloudAIEndpointsEntity(Entity):
"""Base entity for OVHcloud AI Endpoints."""
_attr_has_entity_name = True
def __init__(
self,
entry: OVHcloudAIEndpointsConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self.model = subentry.data[CONF_MODEL]
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None:
"""Generate an answer for the chat log."""
model_args: dict[str, Any] = {
"model": self.model,
}
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
]
if tools:
model_args["tools"] = tools
model_args["messages"] = [
m
for content in chat_log.content
if (m := _convert_content_to_chat_message(content))
]
client = self.entry.runtime_data
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
result = await client.chat.completions.create(**model_args)
except openai.OpenAIError as err:
LOGGER.error("Error talking to API: %s", err)
raise HomeAssistantError("Error talking to API") from err
if not result.choices:
LOGGER.error("API returned empty choices")
raise HomeAssistantError("API returned empty response")
result_message = result.choices[0].message
model_args["messages"].extend(
[
msg
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, _transform_response(result_message)
)
if (msg := _convert_content_to_chat_message(content))
]
)
if not chat_log.unresponded_tool_results:
break
@@ -0,0 +1,13 @@
{
"domain": "ovhcloud_ai_endpoints",
"name": "OVHcloud AI Endpoints",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@Crocmagnon"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["openai==2.21.0"]
}
@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No actions are implemented
appropriate-polling:
status: exempt
comment: the integration does not poll
brands: done
common-modules:
status: exempt
comment: the integration currently implements only one platform and has no coordinator
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No actions are implemented
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: the integration does not subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: configuration is per-subentry; documented via subentry strings
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: the integration only implements a stateless conversation entity.
integration-owner: done
log-when-unavailable:
status: exempt
comment: the integration only integrates stateless entities
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Service can't be discovered
discovery:
status: exempt
comment: Service can't be discovered
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: devices are created via subentries, not discovered dynamically
entity-category:
status: exempt
comment: conversation entity does not use entity categories
entity-device-class:
status: exempt
comment: no suitable device class for the conversation entity
entity-disabled-by-default:
status: exempt
comment: only one conversation entity per subentry
entity-translations:
status: exempt
comment: conversation entity name comes from subentry title
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: the integration has no repairs
stale-devices:
status: exempt
comment: only one device per entry, deleted with the subentry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -0,0 +1,50 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "An OVHcloud AI Endpoints API key"
}
}
}
},
"config_subentries": {
"conversation": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before configuring.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Conversation agent",
"initiate_flow": {
"user": "Add conversation agent"
},
"step": {
"init": {
"data": {
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"model": "[%key:common::generic::model%]",
"prompt": "[%key:common::config_flow::data::prompt%]"
},
"data_description": {
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
"model": "The model to use for the conversation agent",
"prompt": "Instruct how the LLM should respond. This can be a template."
},
"description": "Configure the conversation agent"
}
}
}
}
}
+25
View File
@@ -1,6 +1,7 @@
"""Support for Qbus sensor."""
from dataclasses import dataclass
from enum import StrEnum
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import (
@@ -13,6 +14,7 @@ from qbusmqttapi.state import (
)
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -308,9 +310,32 @@ class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
variant = str(mqtt_output.variant)
self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()]
allowed_units = (
DEVICE_CLASS_UNITS.get(self.entity_description.device_class)
if self.entity_description.device_class
else None
)
value_properties: dict = mqtt_output.properties.get("currentValue", {})
unit = self._find_matching_unit(value_properties.get("unit"), allowed_units)
if allowed_units is not None and unit in allowed_units:
self._attr_native_unit_of_measurement = unit
async def _handle_state_received(self, state: QbusMqttGaugeState) -> None:
self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE)
def _find_matching_unit(
self,
unit: str | None,
allowed_units: set[type[StrEnum] | str | None] | None,
) -> str | None:
"""Do a case-insensitive search in the allowed units. Returns the properly cased unit if found, else None."""
if unit is None or allowed_units is None:
return None
lookup = {str(u).casefold(): str(u) for u in allowed_units if u is not None}
return lookup.get(unit.casefold())
class QbusHumiditySensor(QbusEntity, SensorEntity):
"""Representation of a Qbus sensor entity for humidity modules."""
+1
View File
@@ -548,6 +548,7 @@ FLOWS = {
"ourgroceries",
"overkiz",
"overseerr",
"ovhcloud_ai_endpoints",
"ovo_energy",
"owntracks",
"p1_monitor",
@@ -5193,6 +5193,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"ovhcloud_ai_endpoints": {
"name": "OVHcloud AI Endpoints",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"ovo_energy": {
"name": "OVO Energy",
"integration_type": "service",
Generated
+10
View File
@@ -4037,6 +4037,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.ovhcloud_ai_endpoints.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.p1_monitor.*]
check_untyped_defs = true
disallow_incomplete_defs = true
+1
View File
@@ -1748,6 +1748,7 @@ open-meteo==0.3.2
# homeassistant.components.cloud
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
# homeassistant.components.ovhcloud_ai_endpoints
openai==2.21.0
# homeassistant.components.openerz
+1 -1
View File
@@ -3,4 +3,4 @@
codespell==2.4.2
ruff==0.15.13
yamllint==1.38.0
zizmor==1.24.1
zizmor==1.25.2
-1
View File
@@ -217,7 +217,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"canary",
"cast",
"ccm15",
"cert_expiry",
"chacon_dio",
"channels",
"circuit",
@@ -0,0 +1,21 @@
"""Tests for the OVHcloud AI Endpoints integration."""
from unittest.mock import AsyncMock
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
mock_openai_client.chat.completions.create.reset_mock()
@@ -0,0 +1,126 @@
"""Fixtures for OVHcloud AI Endpoints integration tests."""
from collections.abc import AsyncGenerator, Generator
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from openai.types import CompletionUsage, Model
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
import pytest
from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_PROMPT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_load_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.ovhcloud_ai_endpoints.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def enable_assist() -> bool:
"""Toggle for whether the conversation subentry exposes the Assist API."""
return False
@pytest.fixture
def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]:
"""Mock conversation subentry data."""
res: dict[str, Any] = {
CONF_MODEL: "Meta-Llama-3_3-70B-Instruct",
CONF_PROMPT: "You are a helpful assistant.",
}
if enable_assist:
res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST]
return res
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant,
conversation_subentry_data: dict[str, Any],
) -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
title="OVHcloud AI Endpoints",
domain=DOMAIN,
data={
CONF_API_KEY: "bla",
},
subentries_data=[
ConfigSubentryData(
data=conversation_subentry_data,
subentry_id="ABCDEF",
subentry_type="conversation",
title="Meta-Llama-3_3-70B-Instruct",
unique_id=None,
),
],
)
async def _build_models(hass: HomeAssistant) -> list[Model]:
"""Load mocked models from the fixture file."""
raw = await async_load_fixture(hass, "models.json", DOMAIN)
return [Model.model_validate(m) for m in json.loads(raw)["data"]]
@pytest.fixture
async def mock_openai_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]:
"""Mock the AsyncOpenAI client used by the integration."""
models = await _build_models(hass)
async def _list_models(*args: Any, **kwargs: Any) -> AsyncGenerator[Model]:
for model in models:
yield model
with patch(
"homeassistant.components.ovhcloud_ai_endpoints.AsyncOpenAI"
) as mock_async_openai:
client = mock_async_openai.return_value
client.with_options.return_value = client
client.models.list = MagicMock(side_effect=_list_models)
client.chat.completions.create = AsyncMock(
return_value=ChatCompletion(
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Hello, how can I help you?",
role="assistant",
function_call=None,
tool_calls=None,
),
)
],
created=1700000000,
model="Meta-Llama-3_3-70B-Instruct",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(
completion_tokens=9, prompt_tokens=8, total_tokens=17
),
)
)
yield client
@pytest.fixture(autouse=True)
async def setup_ha(hass: HomeAssistant) -> None:
"""Set up Home Assistant."""
assert await async_setup_component(hass, "homeassistant", {})
@@ -0,0 +1,16 @@
{
"data": [
{
"id": "Meta-Llama-3_3-70B-Instruct",
"object": "model",
"created": 1700000000,
"owned_by": "ovhcloud"
},
{
"id": "Mistral-Nemo-Instruct-2407",
"object": "model",
"created": 1700000000,
"owned_by": "ovhcloud"
}
]
}
@@ -0,0 +1,230 @@
# serializer version: 1
# name: test_all_entities[assist][conversation.meta_llama_3_3_70b_instruct-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'conversation',
'entity_category': None,
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
'conversation': dict({
'should_expose': False,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ovhcloud_ai_endpoints',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ConversationEntityFeature: 1>,
'translation_key': None,
'unique_id': 'ABCDEF',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[assist][conversation.meta_llama_3_3_70b_instruct-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Meta-Llama-3_3-70B-Instruct',
'supported_features': <ConversationEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[no_assist][conversation.meta_llama_3_3_70b_instruct-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'conversation',
'entity_category': None,
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
'conversation': dict({
'should_expose': False,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ovhcloud_ai_endpoints',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'ABCDEF',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[no_assist][conversation.meta_llama_3_3_70b_instruct-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Meta-Llama-3_3-70B-Instruct',
'supported_features': <ConversationEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_default_prompt
list([
dict({
'attachments': None,
'content': 'hello',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'content': 'Hello, how can I help you?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
# name: test_function_call[True]
list([
dict({
'attachments': None,
'content': 'What time is it?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'content': None,
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': list([
dict({
'external': True,
'id': 'mock_tool_call_id',
'tool_args': dict({
}),
'tool_name': 'HassGetCurrentTime',
}),
]),
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock_tool_call_id',
'tool_name': 'HassGetCurrentTime',
'tool_result': dict({
'data': dict({
'failed': list([
]),
'success': list([
]),
}),
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': '12:00 PM',
}),
}),
'speech_slots': dict({
'time': datetime.time(12, 0),
}),
}),
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'content': '12:00 PM',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
dict({
'attachments': None,
'content': 'Please call the test function',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'content': None,
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': list([
dict({
'external': False,
'id': 'call_call_1',
'tool_args': dict({
'param1': 'call1',
}),
'tool_name': 'test_tool',
}),
]),
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'call_call_1',
'tool_name': 'test_tool',
'tool_result': 'value1',
}),
dict({
'agent_id': 'conversation.meta_llama_3_3_70b_instruct',
'content': 'I have successfully called the function',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
@@ -0,0 +1,248 @@
"""Test the OVHcloud AI Endpoints config flow."""
from unittest.mock import AsyncMock
import httpx
from openai import AuthenticationError, OpenAIError
import pytest
from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_PROMPT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry")
async def test_full_flow(hass: HomeAssistant, mock_openai_client: AsyncMock) -> None:
"""Test the full config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "bla"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OVHcloud AI Endpoints"
assert result["data"] == {CONF_API_KEY: "bla"}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_second_account(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that a second account with a different API key can be added."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "different_key"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OVHcloud AI Endpoints"
assert result["data"] == {CONF_API_KEY: "different_key"}
@pytest.mark.parametrize(
("exception", "error"),
[
(
AuthenticationError(
message="invalid key",
response=httpx.Response(
status_code=401,
request=httpx.Request(method="POST", url="https://example.com"),
),
body=None,
),
"invalid_auth",
),
(OpenAIError("boom"), "cannot_connect"),
(Exception("boom"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_form_errors(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test errors raised while validating the API key."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_openai_client.chat.completions.create.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "bla"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_openai_client.chat.completions.create.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "bla"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_duplicate_entry(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test aborting the flow if an entry with the same API key already exists."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "bla"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_create_conversation_agent(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating a conversation agent subentry."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
assert result["step_id"] == "init"
assert result["data_schema"].schema["model"].config["options"] == [
{
"value": "Meta-Llama-3_3-70B-Instruct",
"label": "Meta-Llama-3_3-70B-Instruct",
},
{"value": "Mistral-Nemo-Instruct-2407", "label": "Mistral-Nemo-Instruct-2407"},
]
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_MODEL: "Meta-Llama-3_3-70B-Instruct",
CONF_PROMPT: "you are an assistant",
CONF_LLM_HASS_API: ["assist"],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Meta-Llama-3_3-70B-Instruct"
assert result["data"] == {
CONF_MODEL: "Meta-Llama-3_3-70B-Instruct",
CONF_PROMPT: "you are an assistant",
CONF_LLM_HASS_API: ["assist"],
}
async def test_create_conversation_agent_no_control(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating a conversation agent without LLM API control."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_MODEL: "Mistral-Nemo-Instruct-2407",
CONF_PROMPT: "you are an assistant",
CONF_LLM_HASS_API: [],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_MODEL: "Mistral-Nemo-Instruct-2407",
CONF_PROMPT: "you are an assistant",
}
@pytest.mark.parametrize(
("exception", "reason"),
[
(OpenAIError("boom"), "cannot_connect"),
(Exception("boom"), "unknown"),
],
)
async def test_subentry_exceptions(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
reason: str,
) -> None:
"""Test the subentry flow aborts when the API call fails."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.models.list.side_effect = exception
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_subentry_entry_not_loaded(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the subentry flow aborts when the parent entry is not loaded."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "entry_not_loaded"
@@ -0,0 +1,461 @@
"""Tests for the OVHcloud AI Endpoints conversation entity."""
import datetime
from unittest.mock import AsyncMock
from freezegun import freeze_time
from openai import OpenAIError
from openai.types import CompletionUsage
from openai.types.chat import (
ChatCompletion,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import conversation
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import entity_registry as er, intent
from homeassistant.helpers.llm import ToolInput
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401
@pytest.fixture(autouse=True)
def freeze_the_time():
"""Freeze the time."""
with freeze_time("2024-05-24 12:00:00", tz_offset=0):
yield
@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"])
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity registry snapshot for conversation entities."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_default_prompt(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Test that the default prompt works."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
result = await conversation.async_converse(
hass,
"hello",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert mock_chat_log.content[1:] == snapshot
call = mock_openai_client.chat.completions.create.call_args_list[0][1]
assert call["model"] == "Meta-Llama-3_3-70B-Instruct"
assert "extra_headers" not in call
assert "extra_body" not in call
assert "user" not in call
async def test_thinking_tags_extracted(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""``<think>…</think>`` markup must be extracted into thinking_content."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=ChatCompletion(
id="chatcmpl-thinking",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="<think>Let me think.</think>\n\nThe answer is 42.",
role="assistant",
function_call=None,
tool_calls=None,
),
)
],
created=1700000000,
model="Qwen3-32B",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(
completion_tokens=9, prompt_tokens=8, total_tokens=17
),
)
)
result = await conversation.async_converse(
hass,
"What is the answer?",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assistant = mock_chat_log.content[-1]
assert isinstance(assistant, conversation.AssistantContent)
assert assistant.content == "The answer is 42."
assert assistant.thinking_content == "Let me think."
async def test_thinking_only_response(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""A response containing only ``<think>…</think>`` should leave content as None."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=ChatCompletion(
id="chatcmpl-think-only",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="<think>Reasoning…</think>",
role="assistant",
function_call=None,
tool_calls=None,
),
)
],
created=1700000000,
model="Qwen3-32B",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(
completion_tokens=5, prompt_tokens=8, total_tokens=13
),
)
)
await conversation.async_converse(
hass,
"hello",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assistant = mock_chat_log.content[-1]
assert isinstance(assistant, conversation.AssistantContent)
assert assistant.content is None
assert assistant.thinking_content == "Reasoning…"
def _completion_with_extras(content: str | None, **extras: str) -> ChatCompletion:
"""Build a ChatCompletion whose message carries extra (vLLM) fields."""
return ChatCompletion(
id="chatcmpl-extras",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content=content,
role="assistant",
function_call=None,
tool_calls=None,
**extras,
),
)
],
created=1700000000,
model="Qwen3-32B",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(completion_tokens=9, prompt_tokens=8, total_tokens=17),
)
async def test_reasoning_field_extracted(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Reasoning text in ``message.reasoning`` must populate thinking_content."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=_completion_with_extras(
"The answer is 42.", reasoning="Hidden chain of thought"
)
)
await conversation.async_converse(
hass,
"What is the answer?",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assistant = mock_chat_log.content[-1]
assert isinstance(assistant, conversation.AssistantContent)
assert assistant.content == "The answer is 42."
assert assistant.thinking_content == "Hidden chain of thought"
async def test_reasoning_content_field_extracted(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Reasoning text in ``message.reasoning_content`` must populate thinking_content."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=_completion_with_extras(
"Final answer.", reasoning_content="DeepSeek-style reasoning"
)
)
await conversation.async_converse(
hass,
"Question",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assistant = mock_chat_log.content[-1]
assert isinstance(assistant, conversation.AssistantContent)
assert assistant.content == "Final answer."
assert assistant.thinking_content == "DeepSeek-style reasoning"
async def test_reasoning_priority_over_think_tags(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""``message.reasoning`` wins over inline ``<think>`` markup in content."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=_completion_with_extras(
"<think>from tag</think>actual", reasoning="from field"
)
)
await conversation.async_converse(
hass,
"Question",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assistant = mock_chat_log.content[-1]
assert isinstance(assistant, conversation.AssistantContent)
assert assistant.thinking_content == "from field"
# When the reasoning field is present, content is kept as-is — we trust
# the server to have placed the user-facing answer in `content` already.
assert assistant.content == "<think>from tag</think>actual"
async def test_empty_api_response(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""An empty choices response should yield an error conversation result."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create = AsyncMock(
return_value=ChatCompletion(
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
choices=[],
created=1700000000,
model="Meta-Llama-3_3-70B-Instruct",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8),
)
)
result = await conversation.async_converse(
hass,
"hello",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type == intent.IntentResponseType.ERROR
@pytest.mark.parametrize("enable_assist", [True])
async def test_function_call(
hass: HomeAssistant,
mock_chat_log: MockChatLog, # noqa: F811
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
mock_openai_client: AsyncMock,
) -> None:
"""Test tool calling end-to-end with the conversation entity."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_chat_log.async_add_user_content(
conversation.UserContent(content="What time is it?")
)
mock_chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id="conversation.meta_llama_3_3_70b_instruct",
tool_calls=[
ToolInput(
tool_name="HassGetCurrentTime",
tool_args={},
id="mock_tool_call_id",
external=True,
)
],
)
)
mock_chat_log.async_add_assistant_content_without_tools(
conversation.ToolResultContent(
agent_id="conversation.meta_llama_3_3_70b_instruct",
tool_call_id="mock_tool_call_id",
tool_name="HassGetCurrentTime",
tool_result={
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
"response_type": "action_done",
"speech_slots": {"time": datetime.time(12, 0)},
"data": {"success": [], "failed": []},
},
)
)
mock_chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id="conversation.meta_llama_3_3_70b_instruct",
content="12:00 PM",
)
)
mock_chat_log.mock_tool_results(
{
"call_call_1": "value1",
}
)
mock_openai_client.chat.completions.create.side_effect = (
ChatCompletion(
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
choices=[
Choice(
finish_reason="tool_calls",
index=0,
message=ChatCompletionMessage(
content=None,
role="assistant",
function_call=None,
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id="call_call_1",
function=Function(
arguments='{"param1":"call1"}',
name="test_tool",
),
type="function",
)
],
),
)
],
created=1700000000,
model="Meta-Llama-3_3-70B-Instruct",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(
completion_tokens=9, prompt_tokens=8, total_tokens=17
),
),
ChatCompletion(
id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="I have successfully called the function",
role="assistant",
function_call=None,
tool_calls=None,
),
)
],
created=1700000000,
model="Meta-Llama-3_3-70B-Instruct",
object="chat.completion",
system_fingerprint=None,
usage=CompletionUsage(
completion_tokens=9, prompt_tokens=8, total_tokens=17
),
),
)
result = await conversation.async_converse(
hass,
"Please call the test function",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert mock_chat_log.content[1:] == snapshot
assert mock_openai_client.chat.completions.create.call_count == 2
async def test_openai_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""An OpenAIError from the SDK should surface an error conversation result."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom")
result = await conversation.async_converse(
hass,
"hello",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type == intent.IntentResponseType.ERROR
@@ -0,0 +1,81 @@
"""Tests for the OVHcloud AI Endpoints integration setup."""
from unittest.mock import AsyncMock
from openai import OpenAIError
from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
from homeassistant.const import CONF_API_KEY, CONF_MODEL, CONF_PROMPT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry
async def test_setup_unload(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the integration is set up and torn down cleanly."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_cannot_connect(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that a connection error surfaces a setup retry."""
mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom")
await setup_integration(hass, mock_config_entry, mock_openai_client)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_new_subentry_creates_entity_and_device(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""A subentry added after setup must spawn its conversation entity and device."""
entry = MockConfigEntry(
title="OVHcloud AI Endpoints",
domain=DOMAIN,
data={CONF_API_KEY: "bla"},
)
await setup_integration(hass, entry, mock_openai_client)
assert entry.state is ConfigEntryState.LOADED
assert not er.async_entries_for_config_entry(entity_registry, entry.entry_id)
subentry = ConfigSubentry(
data={
CONF_MODEL: "Meta-Llama-3_3-70B-Instruct",
CONF_PROMPT: "You are a helpful assistant.",
},
subentry_type="conversation",
title="Meta-Llama-3_3-70B-Instruct",
unique_id=None,
)
assert hass.config_entries.async_add_subentry(entry, subentry) is True
await hass.async_block_till_done()
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
assert len(entities) == 1
assert entities[0].domain == "conversation"
assert entities[0].unique_id == subentry.subentry_id
device = device_registry.async_get_device(
identifiers={(DOMAIN, subentry.subentry_id)}
)
assert device is not None
assert device.name == "Meta-Llama-3_3-70B-Instruct"
@@ -223,7 +223,6 @@
"read": true,
"step": 0.1,
"type": "number",
"unit": "ppm",
"write": false
}
}
@@ -245,7 +244,7 @@
"read": true,
"step": 0.1,
"type": "number",
"unit": "kWh",
"unit": "A",
"write": false
}
}
@@ -265,7 +264,7 @@
"read": true,
"step": 0.1,
"type": "number",
"unit": "A",
"unit": "Wh",
"write": false
}
}
@@ -38,7 +38,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_84',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
'unit_of_measurement': 'Wh',
})
# ---
# name: test_sensor[sensor.garage_energie-state]
@@ -47,7 +47,7 @@
'device_class': 'energy',
'friendly_name': 'Energie',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
'unit_of_measurement': 'Wh',
}),
'context': <ANY>,
'entity_id': 'sensor.garage_energie',
@@ -96,7 +96,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_85',
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
'unit_of_measurement': 'm³/h',
})
# ---
# name: test_sensor[sensor.garage_gas-state]
@@ -105,7 +105,7 @@
'device_class': 'volume_flow_rate',
'friendly_name': 'Gas',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
'unit_of_measurement': 'm³/h',
}),
'context': <ANY>,
'entity_id': 'sensor.garage_gas',
@@ -154,7 +154,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_86',
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
'unit_of_measurement': 'm³/h',
})
# ---
# name: test_sensor[sensor.garage_gas_flow-state]
@@ -163,7 +163,7 @@
'device_class': 'volume_flow_rate',
'friendly_name': 'Gas Flow',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
'unit_of_measurement': 'm³/h',
}),
'context': <ANY>,
'entity_id': 'sensor.garage_gas_flow',
@@ -212,7 +212,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_83',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
'unit_of_measurement': 'A',
})
# ---
# name: test_sensor[sensor.garage_stroom-state]
@@ -221,7 +221,7 @@
'device_class': 'current',
'friendly_name': 'Stroom',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
'unit_of_measurement': 'A',
}),
'context': <ANY>,
'entity_id': 'sensor.garage_stroom',
@@ -557,7 +557,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_81',
'unit_of_measurement': <UnitOfPressure.MBAR: 'mbar'>,
'unit_of_measurement': 'mbar',
})
# ---
# name: test_sensor[sensor.tuin_luchtdruk-state]
@@ -566,7 +566,7 @@
'device_class': 'pressure',
'friendly_name': 'Luchtdruk',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPressure.MBAR: 'mbar'>,
'unit_of_measurement': 'mbar',
}),
'context': <ANY>,
'entity_id': 'sensor.tuin_luchtdruk',
@@ -673,7 +673,7 @@
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_40',
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
'unit_of_measurement': 'm',
})
# ---
# name: test_sensor[sensor.tuin_regenput-state]
@@ -682,7 +682,7 @@
'device_class': 'distance',
'friendly_name': 'Regenput',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
'unit_of_measurement': 'm',
}),
'context': <ANY>,
'entity_id': 'sensor.tuin_regenput',
+21 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -25,3 +25,23 @@ async def test_sensor(
await setup_integration_deferred()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_gauge_unit_applied(
hass: HomeAssistant, setup_integration: None
) -> None:
"""Test gauge sensor uses reported unit."""
entity = hass.states.get("sensor.garage_energie")
assert entity
assert entity.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Wh"
async def test_sensor_gauge_unit_missing(
hass: HomeAssistant, setup_integration: None
) -> None:
"""Test gauge sensor falls back to description default."""
entity = hass.states.get("sensor.tuin_luchtkwaliteit")
assert entity
assert entity.attributes[ATTR_UNIT_OF_MEASUREMENT] == "ppm"