Skip to content
Merged
19 changes: 19 additions & 0 deletions homeassistant/components/bosch_shc/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ class SHCSwitchEntityDescription(SwitchEntityDescription):
on_value=ThermostatService.State.ON,
should_poll=False,
),
"presencesimulation": SHCSwitchEntityDescription(
key="presencesimulation",
device_class=SwitchDeviceClass.SWITCH,
on_key="enabled",
on_value=True,
should_poll=False,
),
}


Expand Down Expand Up @@ -207,6 +214,18 @@ async def async_setup_entry(
)
)

presence_simulation_system = session.device_helper.presence_simulation_system
if presence_simulation_system is not None:
entities.append(
SHCSwitch(
hass=hass,
device=presence_simulation_system,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["presencesimulation"],
)
)

async_add_entities(entities)


Expand Down
27 changes: 23 additions & 4 deletions homeassistant/components/climate/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@
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 homeassistant.helpers.llm import (
LLM_API_ASSIST,
IntentTool,
LLMContext,
Tool,
ToolAnnotations,
)

from .const import DOMAIN, INTENT_SET_TEMPERATURE

# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_SET_TEMPERATURE,)
# Each intent sets a value on the user's own entities, so calling one again
# with the same arguments has no further effect.
LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False)

# Intents owned by this integration that are exposed as LLM tools, with the
# title shown for each.
LLM_INTENTS = {
INTENT_SET_TEMPERATURE: "Set temperature",
}


@callback
Expand All @@ -30,7 +43,13 @@ def async_get_tools(
return None

tools: list[Tool] = [
IntentTool(f"{DOMAIN}__{handler.intent_type}", handler)
IntentTool(
f"{DOMAIN}__{handler.intent_type}",
handler,
title=LLM_INTENTS[handler.intent_type],
integration=DOMAIN,
annotations=LLM_ANNOTATIONS,
)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
Expand Down
30 changes: 29 additions & 1 deletion homeassistant/components/mcp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import InitializeResult
from mcp.types import InitializeResult, ToolAnnotations
import probatio

# Imported by name because the tests patch it on this module.
Expand Down Expand Up @@ -121,22 +121,48 @@ async def mcp_client(
raise main_error from streamable_err


def _tool_annotations(remote: ToolAnnotations | None) -> llm.ToolAnnotations:
"""Return the annotations the remote server declares for a tool.

A hint the server leaves out keeps the conservative default.
"""
if remote is None:
return llm.ToolAnnotations()
declared = {
field: value
for field, value in (
("read_only", remote.readOnlyHint),
("destructive", remote.destructiveHint),
("idempotent", remote.idempotentHint),
("open_world", remote.openWorldHint),
)
if value is not None
}
return llm.ToolAnnotations(**declared)


class ModelContextProtocolTool(llm.Tool):
"""A Tool exposed over the Model Context Protocol."""

integration = DOMAIN

def __init__(
self,
name: str,
title: str | None,
description: str | None,
parameters: probatio.Schema,
server_url: str,
config_entry: ConfigEntry,
token_manager: TokenManager | None = None,
annotations: llm.ToolAnnotations = llm.ToolAnnotations(),
) -> None:
"""Initialize the tool."""
self.name = name
self.title = title
self.description = description
self.parameters = parameters
self.annotations = annotations
self.server_url = server_url
self.config_entry = config_entry
self.token_manager = token_manager
Expand Down Expand Up @@ -262,11 +288,13 @@ async def _async_update_data(self) -> list[llm.Tool]:
tools.append(
ModelContextProtocolTool(
tool.name,
tool.title,
tool.description,
parameters,
self.config_entry.data[CONF_URL],
self.config_entry,
self.token_manager,
_tool_annotations(tool.annotations),
)
)
return tools
31 changes: 30 additions & 1 deletion homeassistant/components/mikrotik/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from librouteros import Api

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.util import slugify

from .const import ATTR_MANUFACTURER, DOMAIN
from .coordinator import (
Expand Down Expand Up @@ -56,6 +57,34 @@ async def async_setup_entry(
sw_version=coordinator.firmware,
)

@callback
def _async_remove_stale_devices() -> None:
"""Remove interface devices the hub no longer reports."""
known_identifiers = {(DOMAIN, coordinator.serial_num)}
for interface in coordinator.api.interfaces:
if (mac := interface.get("mac-address")) and (
name := interface.get("name")
):
known_identifiers.add((DOMAIN, f"{slugify(mac)}_{name}"))

for device_entry in dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
):
own_identifiers = {
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
# device-tracker clients are linked by MAC connection only, so an
# entry without an own identifier is never an interface device
if own_identifiers and own_identifiers.isdisjoint(known_identifiers):
device_registry.async_remove_device(device_entry.id)

_async_remove_stale_devices()
config_entry.async_on_unload(
coordinator.async_add_listener(_async_remove_stale_devices)
)

await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

return True
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/mikrotik/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices: todo
stale-devices: done

# Platinum
async-dependency: todo
Expand Down
38 changes: 32 additions & 6 deletions homeassistant/components/openai_conversation/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
EasyInputMessageParam,
FunctionToolParam,
ResponseCodeInterpreterToolCall,
ResponseCodeInterpreterToolCallParam,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
Expand Down Expand Up @@ -41,6 +42,9 @@
ToolParam,
WebSearchToolParam,
)
from openai.types.responses.response_code_interpreter_tool_call_param import (
Output as CodeInterpreterOutputParam,
)
from openai.types.responses.response_create_params import (
Reasoning,
ResponseCreateParamsStreaming,
Expand Down Expand Up @@ -160,6 +164,7 @@ def _convert_content_to_param(
messages: ResponseInputParam = []
reasoning_summary: list[str] = []
web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {}
code_interpreter_calls: dict[str, llm.ToolInput] = {}

for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
Expand All @@ -172,6 +177,24 @@ def _convert_content_to_param(
"status", "completed"
)
messages.append(web_search_call)
elif (
content.tool_name == "code_interpreter"
and content.tool_call_id in code_interpreter_calls
):
tool_call = code_interpreter_calls.pop(content.tool_call_id)
messages.append(
ResponseCodeInterpreterToolCallParam(
type="code_interpreter_call",
id=tool_call.id,
code=tool_call.tool_args["code"],
container_id=cast(str, content.result.data["container_id"]),
outputs=cast(
list[CodeInterpreterOutputParam] | None,
content.result.data["output"],
),
status=content.result.data["status"], # type: ignore[typeddict-item]
)
)
else:
messages.append(
FunctionCallOutput(
Expand Down Expand Up @@ -211,6 +234,10 @@ def _convert_content_to_param(
action=tool_call.tool_args["action"],
status="completed",
)
elif (
tool_call.external and tool_call.tool_name == "code_interpreter"
):
code_interpreter_calls[tool_call.id] = tool_call
else:
messages.append(
ResponseFunctionToolCallParam(
Expand Down Expand Up @@ -311,10 +338,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
llm.ToolInput(
id=event.item.id,
tool_name="code_interpreter",
tool_args={
"code": event.item.code,
"container": event.item.container_id,
},
tool_args={"code": event.item.code},
external=True,
)
]
Expand All @@ -325,11 +349,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"tool_name": "code_interpreter",
"result": llm.ToolResult(
data={
"container_id": event.item.container_id,
"output": (
[output.to_dict() for output in event.item.outputs] # type: ignore[misc]
if event.item.outputs is not None
else None
)
),
"status": event.item.status,
},
error=event.item.status == "failed",
),
Expand Down Expand Up @@ -505,7 +531,7 @@ async def _async_handle_chat_log( # noqa: C901
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
user=chat_log.conversation_id,
safety_identifier=chat_log.conversation_id,
prompt_cache_key=self.subentry.subentry_id,
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES),
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/remote_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def async_setup_entry(
"""Set up the remote calendar platform."""
coordinator = entry.runtime_data
entity = RemoteCalendarEntity(coordinator, entry)
async_add_entities([entity], True)
async_add_entities([entity])


class RemoteCalendarEntity(
Expand Down
6 changes: 0 additions & 6 deletions homeassistant/helpers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
target as target_helpers,
template,
)
from .deprecation import deprecated_hass_argument
from .selector import TargetSelector
from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType

Expand Down Expand Up @@ -348,7 +347,6 @@ def async_prepare_call_from_config(
}


@deprecated_hass_argument(breaks_in_ha_version="2026.10")
def extract_entity_ids(
service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
Expand All @@ -361,7 +359,6 @@ def extract_entity_ids(
).result()


@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_entities[_EntityT: Entity](
entities: Iterable[_EntityT],
service_call: ServiceCall,
Expand Down Expand Up @@ -400,7 +397,6 @@ async def async_extract_entities[_EntityT: Entity](
return found


@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_entity_ids(
service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
Expand All @@ -415,7 +411,6 @@ async def async_extract_entity_ids(
return referenced.referenced | referenced.indirectly_referenced


@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_config_entry_ids(
service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
Expand Down Expand Up @@ -1025,7 +1020,6 @@ def async_register_admin_service(
)


@deprecated_hass_argument(breaks_in_ha_version="2026.10")
@callback
def verify_domain_control(
domain: str,
Expand Down
Loading
Loading