From 793ea1488f6b920c5295478e8d25ad7c43395f47 Mon Sep 17 00:00:00 2001 From: techartdev Date: Fri, 20 Feb 2026 14:23:06 +0200 Subject: [PATCH] Update changelog, enhance connection checks, and add lightweight gateway status check --- CHANGELOG.md | 20 ++++++ custom_components/openclaw/api.py | 80 +++++++++++++++++++---- custom_components/openclaw/coordinator.py | 55 ++++++++-------- custom_components/openclaw/manifest.json | 2 +- 4 files changed, 119 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ee5c2..73d4ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.4] - 2026-02-20 + +### Fixed +- **Connection probe no longer uses `/v1/models`** — the OpenClaw gateway does + not implement that endpoint (only `/v1/chat/completions` is registered when + `enable_openai_api` is enabled). Unrecognised routes fall through to the SPA + catch-all and return HTML, which caused every connection check to fail with + `openai_api_disabled` even when the API was actually enabled. +- `async_check_connection()` now POSTs to `/v1/chat/completions` with an empty + messages body. The gateway's auth middleware validates the token first, then + the endpoint returns a JSON error for the invalid body — proving server is + reachable, API is enabled, and the token is accepted. No LLM call is made. +- Coordinator polling now uses `async_check_alive()` (lightweight base-URL GET) + for connectivity, with `async_get_models()` as a best-effort call that is + silently ignored if the endpoint doesn't exist. + +### Added +- New `async_check_alive()` method — simple HTTP GET to the gateway base URL to + confirm the gateway process is running (does not verify auth or API status). + ## [0.1.3] - 2026-02-20 ### Fixed diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index fdcdcea..b629128 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -287,27 +287,85 @@ class OpenClawApiClient: ) from err async def async_check_connection(self) -> bool: - """Check if the gateway is reachable and authenticated. + """Check if the gateway is reachable, API is enabled, and auth works. - Uses the /v1/models endpoint as a lightweight probe — the only - consistently available GET endpoint on the OpenClaw gateway. + The OpenClaw gateway only implements /v1/chat/completions (not + /v1/models). We send a POST with an empty messages list — the gateway + auth middleware validates the token first, then the endpoint returns a + 400 (or similar) for the invalid body. This proves: + - Server is reachable. + - The OpenAI-compatible API layer is enabled (enable_openai_api). + - The auth token is accepted. - The /v1/models endpoint requires 'enable_openai_api' to be enabled in - the addon configuration. If the gateway returns HTML instead of JSON, - the OpenAI-compatible API is likely disabled and an OpenClawApiError - is raised so the caller can surface a meaningful error. + If the route is not registered (API disabled) the SPA catch-all + returns 200 text/html — detected via content-type check. Returns: True if connected and authenticated. Raises: OpenClawAuthError: If authentication fails. - OpenClawApiError: If the gateway returns an unexpected response - (e.g. HTML when enable_openai_api is disabled). + OpenClawApiError: If the gateway returns HTML (API not enabled). OpenClawConnectionError: If the gateway is unreachable. """ - await self.async_get_models() - return True + session = await self._get_session() + url = f"{self._base_url}{API_CHAT_COMPLETIONS}" + + try: + async with session.post( + url, + headers=self._headers(), + json={"messages": [], "stream": False}, + timeout=API_TIMEOUT, + ) as resp: + if resp.status in (401, 403): + raise OpenClawAuthError( + "Authentication failed — check gateway token" + ) + + content_type = resp.content_type or "" + if "json" not in content_type: + text = await resp.text() + raise OpenClawApiError( + f"Gateway returned '{content_type}' instead of JSON. " + "The OpenAI-compatible API is likely not enabled. " + "Enable 'enable_openai_api' in the addon settings " + f"and restart. Response: {text[:200]}" + ) + + # Any JSON response (200, 400, 422, etc.) means the + # endpoint exists, auth passed, and the API layer is active. + return True + + except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err: + raise OpenClawConnectionError( + f"Cannot connect to OpenClaw gateway at {url}: {err}" + ) from err + + async def async_check_alive(self) -> bool: + """Lightweight connectivity check — is the gateway process running? + + Sends a GET to the base URL. The SPA catch-all returns 200 HTML for + any route, so any non-error HTTP response means the server is alive. + Auth is NOT verified here (the SPA ignores tokens). + + Returns: + True if the gateway HTTP server is responding. + + Raises: + OpenClawConnectionError: If the gateway is unreachable. + """ + session = await self._get_session() + try: + async with session.get( + self._base_url, + timeout=API_TIMEOUT, + ) as resp: + return resp.status < 500 + except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err: + raise OpenClawConnectionError( + f"Cannot connect to OpenClaw gateway: {err}" + ) from err async def async_close(self) -> None: """Close the HTTP session.""" diff --git a/custom_components/openclaw/coordinator.py b/custom_components/openclaw/coordinator.py index 3878bf4..3db0fcd 100644 --- a/custom_components/openclaw/coordinator.py +++ b/custom_components/openclaw/coordinator.py @@ -80,42 +80,31 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the OpenClaw gateway. - The OpenClaw gateway only exposes OpenAI-compatible endpoints. - /v1/models is used as both the connectivity probe and the model - data source; /api/status and /api/sessions do not exist on the - gateway — it returns its SPA home page for unknown routes. + The OpenClaw gateway does not implement /v1/models — only + /v1/chat/completions is guaranteed. We use a lightweight base-URL + ping (``async_check_alive``) to confirm the gateway process is + running and then attempt ``async_get_models`` as a best-effort call. Returns: Aggregated data dict for all entities. """ data = self._offline_data() + # ── Connectivity check (base URL ping) ───────────────────── try: - models_resp = await self.client.async_get_models() - models = models_resp.get("data", []) + alive = await self.client.async_check_alive() + if not alive: + return data data[DATA_STATUS] = "online" data[DATA_CONNECTED] = True - data[DATA_GATEWAY_VERSION] = None # Not exposed by gateway API - data[DATA_UPTIME] = None # Not exposed by gateway API - data[DATA_SESSION_COUNT] = 0 # Sessions API does not exist - data[DATA_SESSIONS] = [] # Sessions API does not exist + data[DATA_GATEWAY_VERSION] = None + data[DATA_UPTIME] = None + data[DATA_SESSION_COUNT] = 0 + data[DATA_SESSIONS] = [] data[DATA_LAST_ACTIVITY] = self._last_activity self._consecutive_failures = 0 - if models: - current = models[0] - self._model_cache = { - DATA_MODEL: current.get("id", "unknown"), - DATA_PROVIDER: current.get("owned_by"), - DATA_CONTEXT_WINDOW: current.get("context_window"), - } - - except OpenClawAuthError as err: - _LOGGER.warning("Gateway auth failed during poll: %s", err) - await self._try_refresh_token() - return data - except OpenClawConnectionError: self._consecutive_failures += 1 if self._consecutive_failures <= 3: @@ -127,9 +116,23 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) return data - except OpenClawApiError as err: - _LOGGER.warning("Error fetching gateway models: %s", err) - return data + # ── Best-effort model info (/v1/models may not exist) ────── + try: + models_resp = await self.client.async_get_models() + models = models_resp.get("data", []) + if models: + current = models[0] + self._model_cache = { + DATA_MODEL: current.get("id", "unknown"), + DATA_PROVIDER: current.get("owned_by"), + DATA_CONTEXT_WINDOW: current.get("context_window"), + } + except OpenClawAuthError as err: + _LOGGER.warning("Gateway auth failed during poll: %s", err) + await self._try_refresh_token() + except OpenClawApiError: + # /v1/models not implemented — expected, not an error + pass data.update(self._model_cache) return data diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 6478a72..a6388f4 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "requirements": [], - "version": "0.1.3", + "version": "0.1.4", "dependencies": ["conversation"], "after_dependencies": ["hassio"] }