Skip to content

Commit da90e24

Browse files
committed
fix: align service metadata and stabilize testability
Align declared services and translation keys with registered handlers so users only see supported actions. Improve local and CI test reliability by adding lightweight import fallbacks and a dedicated pytest workflow.
1 parent ef26d50 commit da90e24

16 files changed

Lines changed: 245 additions & 112 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Unit Tests
2+
3+
on:
4+
push:
5+
paths-ignore:
6+
- '.github/workflows/**'
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v5
14+
- name: Set up Python
15+
uses: actions/setup-python@v5
16+
with:
17+
python-version: '3.13'
18+
- name: Install test dependencies
19+
run: python -m pip install --upgrade pip && pip install pytest aiohttp
20+
- name: Run tests
21+
run: pytest -q

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ env/
3030

3131
# 测试和覆盖率
3232
.pytest_cache/
33+
.mypy_cache/
34+
.ruff_cache/
3335
.coverage
3436
htmlcov/
3537
.tox/

custom_components/ai_hub/__init__.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,41 @@
77
from typing import Any, TypeAlias
88

99
import aiohttp
10-
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
11-
from homeassistant.const import CONF_API_KEY, Platform
12-
from homeassistant.core import HomeAssistant
13-
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
10+
11+
try:
12+
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
13+
from homeassistant.const import CONF_API_KEY, Platform
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
16+
except ModuleNotFoundError: # pragma: no cover - used only in lightweight test environments
17+
ConfigEntry = Any # type: ignore[assignment]
18+
ConfigSubentry = Any # type: ignore[assignment]
19+
HomeAssistant = Any # type: ignore[assignment]
20+
CONF_API_KEY = "api_key"
21+
22+
class ConfigEntryAuthFailed(Exception):
23+
"""Fallback exception when Home Assistant is not installed."""
24+
25+
class ConfigEntryNotReady(Exception):
26+
"""Fallback exception when Home Assistant is not installed."""
27+
28+
Platform = None # type: ignore[assignment]
1429

1530
from .const import AI_HUB_CHAT_URL, DOMAIN
1631

1732
_LOGGER = logging.getLogger(__name__)
1833

19-
PLATFORMS = [
20-
Platform.CONVERSATION,
21-
Platform.AI_TASK,
22-
Platform.TTS,
23-
Platform.STT,
24-
Platform.BUTTON,
25-
Platform.SENSOR,
26-
]
34+
if Platform is None:
35+
PLATFORMS: list[Any] = []
36+
else:
37+
PLATFORMS = [
38+
Platform.CONVERSATION,
39+
Platform.AI_TASK,
40+
Platform.TTS,
41+
Platform.STT,
42+
Platform.BUTTON,
43+
Platform.SENSOR,
44+
]
2745

2846
AIHubConfigEntry: TypeAlias = ConfigEntry # Store API key
2947

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""API client helpers for AI Hub integration."""
2+
3+
from .base import (
4+
APIClient,
5+
APIError,
6+
APIResponse,
7+
AuthenticationError,
8+
RateLimitError,
9+
TimeoutError,
10+
)
11+
12+
__all__ = [
13+
"APIClient",
14+
"APIError",
15+
"APIResponse",
16+
"AuthenticationError",
17+
"RateLimitError",
18+
"TimeoutError",
19+
]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Lightweight API client base classes used by tests and services."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
import aiohttp
10+
11+
12+
@dataclass
13+
class APIResponse:
14+
"""Normalized API response container."""
15+
16+
success: bool
17+
data: Any = None
18+
status_code: int | None = None
19+
20+
@property
21+
def is_error(self) -> bool:
22+
return not self.success
23+
24+
def get_error_message(self) -> str | None:
25+
if self.success:
26+
return None
27+
if isinstance(self.data, str):
28+
return self.data
29+
if isinstance(self.data, dict):
30+
if isinstance(self.data.get("error"), str):
31+
return self.data["error"]
32+
if isinstance(self.data.get("message"), str):
33+
return self.data["message"]
34+
nested_error = self.data.get("error")
35+
if isinstance(nested_error, dict) and isinstance(nested_error.get("message"), str):
36+
return nested_error["message"]
37+
return None
38+
39+
40+
class APIError(Exception):
41+
"""Base API exception."""
42+
43+
def __init__(
44+
self,
45+
message: str,
46+
*,
47+
status_code: int | None = None,
48+
response_body: Any = None,
49+
) -> None:
50+
super().__init__(message)
51+
self.status_code = status_code
52+
self.response_body = response_body
53+
54+
55+
class AuthenticationError(APIError):
56+
"""Raised for authentication failures."""
57+
58+
59+
class RateLimitError(APIError):
60+
"""Raised when API rate limit is exceeded."""
61+
62+
def __init__(self, message: str, *, status_code: int | None = None, retry_after: float | None = None) -> None:
63+
super().__init__(message, status_code=status_code)
64+
self.retry_after = retry_after
65+
66+
67+
class TimeoutError(APIError):
68+
"""Raised for API timeout failures."""
69+
70+
71+
class APIClient(ABC):
72+
"""Abstract API client with shared session handling."""
73+
74+
def __init__(self, api_key: str, session: aiohttp.ClientSession | None = None) -> None:
75+
self._api_key = api_key
76+
self._session = session
77+
self._own_session = session is None
78+
79+
@property
80+
def api_name(self) -> str:
81+
return self.__class__.__name__
82+
83+
@abstractmethod
84+
def _get_base_url(self) -> str:
85+
"""Return API base URL."""
86+
87+
def _get_default_headers(self) -> dict[str, str]:
88+
return {
89+
"Authorization": f"Bearer {self._api_key}",
90+
"Content-Type": "application/json",
91+
}
92+
93+
async def _ensure_session(self) -> aiohttp.ClientSession:
94+
if self._session is None or self._session.closed:
95+
self._session = aiohttp.ClientSession()
96+
self._own_session = True
97+
return self._session
98+
99+
async def close(self) -> None:
100+
if self._session is not None and self._own_session and not self._session.closed:
101+
await self._session.close()
102+
103+
async def __aenter__(self) -> "APIClient":
104+
await self._ensure_session()
105+
return self
106+
107+
async def __aexit__(self, exc_type, exc, tb) -> None:
108+
await self.close()
109+
110+
def _extract_error_message(self, payload: Any) -> str | None:
111+
if isinstance(payload, str):
112+
return payload
113+
if isinstance(payload, dict):
114+
if isinstance(payload.get("error"), str):
115+
return payload["error"]
116+
if isinstance(payload.get("message"), str):
117+
return payload["message"]
118+
nested_error = payload.get("error")
119+
if isinstance(nested_error, dict):
120+
nested_message = nested_error.get("message")
121+
if isinstance(nested_message, str):
122+
return nested_message
123+
return None

custom_components/ai_hub/config_flow.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,13 @@ async def async_step_user(
145145

146146
try:
147147
await validate_input(self.hass, user_input)
148-
except ValueError:
149-
_LOGGER.exception("Invalid API key")
150-
errors["base"] = "invalid_auth"
148+
except ValueError as err:
149+
reason = str(err)
150+
if reason in {"invalid_auth", "cannot_connect"}:
151+
errors["base"] = reason
152+
else:
153+
_LOGGER.exception("Unexpected validation error: %s", err)
154+
errors["base"] = "unknown"
151155
except aiohttp.ClientError:
152156
_LOGGER.exception("Cannot connect")
153157
errors["base"] = "cannot_connect"

custom_components/ai_hub/const.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
import logging
2525
from typing import Any, Final
2626

27-
from homeassistant.core import HomeAssistant
27+
try:
28+
from homeassistant.core import HomeAssistant
29+
except ModuleNotFoundError: # pragma: no cover - used only in lightweight test environments
30+
HomeAssistant = Any # type: ignore[assignment]
2831

2932
# Import llm for API constants
3033
try:

custom_components/ai_hub/diagnostics.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,23 @@
1818
from datetime import datetime
1919
from typing import Any
2020

21-
from homeassistant.components.diagnostics import async_redact_data
22-
from homeassistant.config_entries import ConfigEntry
23-
from homeassistant.core import HomeAssistant
21+
try:
22+
from homeassistant.components.diagnostics import async_redact_data
23+
from homeassistant.config_entries import ConfigEntry
24+
from homeassistant.core import HomeAssistant
25+
except ModuleNotFoundError: # pragma: no cover - used only in lightweight test environments
26+
ConfigEntry = Any # type: ignore[assignment]
27+
HomeAssistant = Any # type: ignore[assignment]
28+
29+
def async_redact_data(data: dict[str, Any], to_redact: set[str]) -> dict[str, Any]:
30+
"""Fallback redaction helper when Home Assistant is unavailable."""
31+
redacted = {}
32+
for key, value in data.items():
33+
if key in to_redact:
34+
redacted[key] = "REDACTED"
35+
else:
36+
redacted[key] = value
37+
return redacted
2438

2539
from .const import (
2640
CONF_API_KEY,

custom_components/ai_hub/providers/__init__.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,24 +215,13 @@ def get_registry() -> UnifiedProviderRegistry:
215215
def _register_builtin_providers(registry: UnifiedProviderRegistry) -> None:
216216
"""Register all built-in providers."""
217217
# Import and register LLM providers
218-
try:
219-
from .siliconflow import SiliconFlowProvider
220-
221-
registry.register(
222-
SiliconFlowProvider,
223-
is_default=True,
224-
requires_api_key=True,
225-
description="SiliconFlow (硅基流动) - Free tier available",
226-
)
227-
except ImportError as e:
228-
_LOGGER.debug("SiliconFlow provider not available: %s", e)
229218

230219
try:
231220
from .openai_compatible import OpenAICompatibleProvider
232221

233222
registry.register(
234223
OpenAICompatibleProvider,
235-
is_default=False,
224+
is_default=True,
236225
requires_api_key=True,
237226
description="OpenAI-compatible API (OpenAI, Azure, local LLMs)",
238227
)

custom_components/ai_hub/services.yaml

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -126,40 +126,6 @@ create_automation:
126126
selector:
127127
text:
128128

129-
get_automation_templates:
130-
name: 获取自动化模板
131-
description: 获取所有已创建的自动化模板
132-
133-
delete_automation_template:
134-
name: 删除自动化模板
135-
description: 删除指定的自动化模板
136-
fields:
137-
template_id:
138-
name: 模板ID
139-
description: 要删除的模板ID
140-
required: true
141-
selector:
142-
text:
143-
144-
create_dashboard:
145-
name: 创建仪表板
146-
description: 通过自然语言描述创建Home Assistant仪表板
147-
fields:
148-
description:
149-
name: 仪表板描述
150-
description: 描述你想要的仪表板内容和布局
151-
required: true
152-
example: "创建一个包含温度传感器和灯光控制的主页面仪表板"
153-
selector:
154-
text:
155-
multiline: true
156-
dashboard_config:
157-
name: 仪表板配置
158-
description: 可选的自定义仪表板配置(JSON格式)
159-
required: false
160-
selector:
161-
text:
162-
multiline: true
163129
send_wechat_message:
164130
name: 发送微信消息
165131
description: 通过巴法云发送微信消息通知,设备标题显示实体名称和状态(需要Bemfa UID)

0 commit comments

Comments
 (0)