diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 76867b6c85db9b..3746705510b3c5 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -1,7 +1,16 @@ -"""Model Context Protocol transport protocol for Server Sent Events (SSE). +"""Model Context Protocol transport protocol for Streamable HTTP and SSE. -This registers HTTP endpoints that supports SSE as a transport layer -for the Model Context Protocol. There are two HTTP endpoints: +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: + +- /api/mcp_server: 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 older SSE protocol has two HTTP endpoints: - /mcp_server/sse: The SSE endpoint that is used to establish a session with the client and glue to the MCP server. This is used to push responses @@ -14,6 +23,9 @@ See https://modelcontextprotocol.io/docs/concepts/transports """ +import asyncio +from dataclasses import dataclass +from http import HTTPStatus import logging from aiohttp import web @@ -21,13 +33,14 @@ from aiohttp_sse import sse_response import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp import types +from mcp import JSONRPCRequest, types +from mcp.server import InitializationOptions, Server from mcp.shared.message import SessionMessage from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_LLM_HASS_API -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import llm from .const import DOMAIN @@ -37,6 +50,14 @@ _LOGGER = logging.getLogger(__name__) +# Streamable HTTP endpoint +STREAMABLE_API = f"/api/{DOMAIN}" +TIMEOUT = 60 # Seconds + +# Content types +CONTENT_TYPE_JSON = "application/json" + +# Legacy SSE endpoint SSE_API = f"/{DOMAIN}/sse" MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}" @@ -46,6 +67,7 @@ def async_register(hass: HomeAssistant) -> None: """Register the websocket API.""" hass.http.register_view(ModelContextProtocolSSEView()) hass.http.register_view(ModelContextProtocolMessagesView()) + hass.http.register_view(ModelContextProtocolStreamableView()) def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: @@ -66,6 +88,52 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: return config_entries[0] +@dataclass +class Streams: + """Pairs of streams for MCP server communication.""" + + # The MCP server reads from the read stream. The HTTP handler receives + # incoming client messages and writes the to the read_stream_writer. + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] + + # The MCP server writes to the write stream. The HTTP handler reads from + # the write stream and sends messages to the client. + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] + + +def create_streams() -> Streams: + """Create a new pair of streams for MCP server communication.""" + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) + write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + return Streams( + read_stream=read_stream, + read_stream_writer=read_stream_writer, + write_stream=write_stream, + write_stream_reader=write_stream_reader, + ) + + +async def create_mcp_server( + hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry +) -> tuple[Server, InitializationOptions]: + """Initialize the MCP server to ensure it's ready to handle requests.""" + llm_context = llm.LLMContext( + platform=DOMAIN, + context=context, + language="*", + 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 + ) + return server, options + + class ModelContextProtocolSSEView(HomeAssistantView): """Model Context Protocol SSE endpoint.""" @@ -86,30 +154,12 @@ async def get(self, request: web.Request) -> web.StreamResponse: entry = async_get_config_entry(hass) session_manager = entry.runtime_data - context = llm.LLMContext( - platform=DOMAIN, - context=self.context(request), - language="*", - assistant=conversation.DOMAIN, - device_id=None, - ) - llm_api_id = entry.data[CONF_LLM_HASS_API] - server = await create_server(hass, llm_api_id, context) - options = await hass.async_add_executor_job( - server.create_initialization_options # Reads package for version info - ) - - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + server, options = await create_mcp_server(hass, self.context(request), entry) + streams = create_streams() async with ( sse_response(request) as response, - session_manager.create(Session(read_stream_writer)) as session_id, + session_manager.create(Session(streams.read_stream_writer)) as session_id, ): session_uri = MESSAGES_API.format(session_id=session_id) _LOGGER.debug("Sending SSE endpoint: %s", session_uri) @@ -117,7 +167,7 @@ async def get(self, request: web.Request) -> web.StreamResponse: async def sse_reader() -> None: """Forward MCP server responses to the client.""" - async for session_message in write_stream_reader: + async for session_message in streams.write_stream_reader: _LOGGER.debug("Sending SSE message: %s", session_message) await response.send( session_message.message.model_dump_json( @@ -128,7 +178,7 @@ async def sse_reader() -> None: async with anyio.create_task_group() as tg: tg.start_soon(sse_reader) - await server.run(read_stream, write_stream, options) + await server.run(streams.read_stream, streams.write_stream, options) return response @@ -168,3 +218,64 @@ async def post( _LOGGER.debug("Received client message: %s", message) await session.read_stream_writer.send(SessionMessage(message)) return web.Response(status=200) + + +class ModelContextProtocolStreamableView(HomeAssistantView): + """Model Context Protocol Streamable HTTP endpoint.""" + + 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.""" + hass = request.app[KEY_HASS] + entry = async_get_config_entry(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, self.context(request), entry) + streams = create_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), + ) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 2581698e185b48..95ddcc402c0d1f 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -95,6 +95,7 @@ def _convert_content( return ollama.Message( role=MessageRole.ASSISTANT.value, content=chat_content.content, + thinking=chat_content.thinking_content, tool_calls=[ ollama.Message.ToolCall( function=ollama.Message.ToolCall.Function( @@ -103,7 +104,8 @@ def _convert_content( ) ) for tool_call in chat_content.tool_calls or () - ], + ] + or None, ) if isinstance(chat_content, conversation.UserContent): images: list[ollama.Image] = [] @@ -162,6 +164,8 @@ async def _transform_stream( ] if (content := response_message.get("content")) is not None: chunk["content"] = content + if (thinking := response_message.get("thinking")) is not None: + chunk["thinking_content"] = thinking if response_message.get("done"): new_msg = True yield chunk diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index d322504a1d9063..8ad3ede39606bc 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -336,6 +336,7 @@ def partition_usage( key="power_usage", translation_key="power_usage", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, icon="mdi:power-plug", @@ -577,7 +578,6 @@ async def async_setup_entry( key=f"gpu_{gpu.id}_power_usage", name=f"{gpu.name} power usage", entity_registry_enabled_default=False, - device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, value=lambda data, k=index: gpu_power_usage(data, k), diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index f1f820fa734f63..71a349916d316f 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1501,41 +1501,42 @@ async def async_step_esphome( if not is_hassio(self.hass): return self.async_abort(reason="not_hassio") - if ( - discovery_info.zwave_home_id - and ( - current_config_entries := self._async_current_entries( - include_ignore=False + if discovery_info.zwave_home_id: + if ( + ( + current_config_entries := self._async_current_entries( + include_ignore=False + ) ) - ) - and (home_id := str(discovery_info.zwave_home_id)) - and ( - existing_entry := next( - ( - entry - for entry in current_config_entries - if entry.unique_id == home_id - ), - None, + and (home_id := str(discovery_info.zwave_home_id)) + and ( + existing_entry := next( + ( + entry + for entry in current_config_entries + if entry.unique_id == home_id + ), + None, + ) ) + # Only update existing entries that are configured via sockets + and existing_entry.data.get(CONF_SOCKET_PATH) + # And use the add-on + and existing_entry.data.get(CONF_USE_ADDON) + ): + await self._async_set_addon_config( + {CONF_ADDON_SOCKET: discovery_info.socket_path} + ) + # Reloading will sync add-on options to config entry data + self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) + return self.async_abort(reason="already_configured") + + # We are not aborting if home ID configured here, we just want to make sure that it's set + # We will update a USB based config entry automatically in `async_step_finish_addon_setup_user` + await self.async_set_unique_id( + str(discovery_info.zwave_home_id), raise_on_progress=False ) - # Only update existing entries that are configured via sockets - and existing_entry.data.get(CONF_SOCKET_PATH) - # And use the add-on - and existing_entry.data.get(CONF_USE_ADDON) - ): - await self._async_set_addon_config( - {CONF_ADDON_SOCKET: discovery_info.socket_path} - ) - # Reloading will sync add-on options to config entry data - self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) - return self.async_abort(reason="already_configured") - # We are not aborting if home ID configured here, we just want to make sure that it's set - # We will update a USB based config entry automatically in `async_step_finish_addon_setup_user` - await self.async_set_unique_id( - str(discovery_info.zwave_home_id), raise_on_progress=False - ) self.socket_path = discovery_info.socket_path self.context["title_placeholders"] = { CONF_NAME: f"{discovery_info.name} via ESPHome" diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index e1c8801f51b289..9cc9c76f9bd08d 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -5,11 +5,13 @@ from http import HTTPStatus import json import logging +from typing import Any import aiohttp import mcp import mcp.client.session import mcp.client.sse +import mcp.client.streamable_http from mcp.shared.exceptions import McpError import pytest @@ -17,7 +19,11 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.mcp_server.const import STATELESS_LLM_API -from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API +from homeassistant.components.mcp_server.http import ( + MESSAGES_API, + SSE_API, + STREAMABLE_API, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -275,16 +281,26 @@ async def test_http_requires_authentication( assert response.status == HTTPStatus.UNAUTHORIZED +@pytest.fixture(params=["sse", "streamable"]) +def mcp_protocol(request: pytest.FixtureRequest): + """Fixture to parametrize tests with different MCP protocols.""" + return request.param + + @pytest.fixture -async def mcp_sse_url(hass_client: ClientSessionGenerator) -> str: - """Fixture to get the MCP integration SSE URL.""" +async def mcp_url(mcp_protocol: str, hass_client: ClientSessionGenerator) -> str: + """Fixture to get the MCP integration URL.""" + if mcp_protocol == "sse": + url = SSE_API + else: + url = STREAMABLE_API client = await hass_client() - return str(client.make_url(SSE_API)) + return str(client.make_url(url)) @asynccontextmanager -async def mcp_session( - mcp_sse_url: str, +async def mcp_sse_session( + mcp_url: str, hass_supervisor_access_token: str, ) -> AsyncGenerator[mcp.client.session.ClientSession]: """Create an MCP session.""" @@ -292,23 +308,55 @@ async def mcp_session( headers = {"Authorization": f"Bearer {hass_supervisor_access_token}"} async with ( - mcp.client.sse.sse_client(mcp_sse_url, headers=headers) as streams, + mcp.client.sse.sse_client(mcp_url, headers=headers) as streams, mcp.client.session.ClientSession(*streams) as session, ): await session.initialize() yield session +@asynccontextmanager +async def mcp_streamable_session( + mcp_url: str, + hass_supervisor_access_token: str, +) -> AsyncGenerator[mcp.client.session.ClientSession]: + """Create an MCP session.""" + + headers = {"Authorization": f"Bearer {hass_supervisor_access_token}"} + + async with ( + mcp.client.streamable_http.streamablehttp_client(mcp_url, headers=headers) as ( + read_stream, + write_stream, + _, + ), + mcp.client.session.ClientSession(read_stream, write_stream) as session, + ): + await session.initialize() + yield session + + +@pytest.fixture(name="mcp_client") +def mcp_client_fixture(mcp_protocol: str) -> Any: + """Fixture to parametrize tests with different MCP clients.""" + if mcp_protocol == "sse": + return mcp_sse_session + if mcp_protocol == "streamable": + return mcp_streamable_session + raise ValueError(f"Unknown MCP protocol: {mcp_protocol}") + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tools_list( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tools list endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.list_tools() # Pick a single arbitrary tool and test that description and parameters @@ -326,7 +374,8 @@ async def test_mcp_tools_list( async def test_mcp_tool_call( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tool call endpoint.""" @@ -335,7 +384,7 @@ async def test_mcp_tool_call( assert state assert state.state == STATE_OFF - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", arguments={"name": "kitchen light"}, @@ -358,12 +407,13 @@ async def test_mcp_tool_call( async def test_mcp_tool_call_failed( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tool call endpoint with a failure.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", arguments={"name": "backyard"}, @@ -379,12 +429,13 @@ async def test_mcp_tool_call_failed( async def test_prompt_list( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the list prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.list_prompts() assert len(result.prompts) == 1 @@ -397,12 +448,13 @@ async def test_prompt_list( async def test_prompt_get( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the get prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.get_prompt(name="Assist") assert result.description == "Default prompt for Home Assistant Assist API" @@ -413,14 +465,15 @@ async def test_prompt_get( assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX) -async def test_get_unknwon_prompt( +async def test_get_unknown_prompt( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the get prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: with pytest.raises(McpError): await session.get_prompt(name="Unknown") diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 4904829a31c50e..4e5ddf286ba1df 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -145,6 +145,70 @@ async def test_chat_stream( assert result.response.speech["plain"]["speech"] == "test response" +async def test_thinking_content( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test that thinking content is retained in multi-turn conversation.""" + + entry = MockConfigEntry() + entry.add_to_hass(hass) + + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + ollama.CONF_THINK: True, + }, + ) + + conversation_id = "conversation_id_1234" + + with patch( + "ollama.AsyncClient.chat", + return_value=stream_generator( + { + "message": { + "role": "assistant", + "content": "test response", + "thinking": "test thinking", + }, + "done": True, + "done_reason": "stop", + }, + ), + ) as mock_chat: + await conversation.async_converse( + hass, + "test message", + conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + await conversation.async_converse( + hass, + "test message 2", + conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert mock_chat.call_count == 2 + assert mock_chat.call_args.kwargs["messages"][1:] == [ + Message(role="user", content="test message"), + Message( + role="assistant", + content="test response", + thinking="test thinking", + ), + Message(role="user", content="test message 2"), + ] + + async def test_template_variables( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 6310c368fc473c..9b006e008afec0 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -51,7 +51,6 @@ "port": 3001, } - ESPHOME_DISCOVERY_INFO = ESPHomeServiceInfo( name="mock-name", zwave_home_id=1234, @@ -59,6 +58,13 @@ port=6053, ) +ESPHOME_DISCOVERY_INFO_CLEAN = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=None, + ip_address="192.168.1.100", + port=6053, +) + USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", @@ -1167,31 +1173,22 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert "keep_old_devices" in entry.data +@pytest.mark.parametrize( + "service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN] +) @pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") async def test_esphome_discovery_intent_custom( hass: HomeAssistant, install_addon: AsyncMock, set_addon_options: AsyncMock, start_addon: AsyncMock, + service_info: ESPHomeServiceInfo, ) -> None: """Test ESPHome discovery success path.""" - # Make sure it works only on hassio - with patch( - "homeassistant.components.zwave_js.config_flow.is_hassio", return_value=False - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ESPHOME}, - data=ESPHOME_DISCOVERY_INFO, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_hassio" - - # Test working version result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ESPHOME}, - data=ESPHOME_DISCOVERY_INFO, + data=service_info, ) assert result["type"] is FlowResultType.MENU @@ -1272,7 +1269,7 @@ async def test_esphome_discovery_intent_custom( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE - assert result["result"].unique_id == str(ESPHOME_DISCOVERY_INFO.zwave_home_id) + assert result["result"].unique_id == "1234" assert result["data"] == { "url": "ws://host1:3001", "usb_path": None, @@ -1524,6 +1521,21 @@ async def test_esphome_discovery_usb_same_home_id( } +@pytest.mark.usefixtures("supervisor") +async def test_esphome_discovery_not_hassio(hass: HomeAssistant) -> None: + """Test ESPHome discovery aborts when not hassio.""" + with patch( + "homeassistant.components.zwave_js.config_flow.is_hassio", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant,