diff --git a/.env.example b/.env.example index 4abd352..738bb77 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,5 @@ TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback TICKTICK_TOKEN= HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_AUTH_TOKEN= +HOME_ASSISTANT_TIMEOUT_SECONDS=1.0 HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= diff --git a/README.md b/README.md index 9079de9..328fe20 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - SQLite + SQLAlchemy + Alembic 基础设施 - 极简 server-side templates - location recorder 第一版迁移 +- Home Assistant outbound integration layer - pytest 测试基础 - OpenAPI 导出脚本 - Docker / Compose 基础骨架 @@ -18,7 +19,7 @@ 当前阶段明确不包含: - TickTick 业务逻辑迁移 -- Home Assistant 业务逻辑迁移 +- Home Assistant inbound command gateway - poo records 业务迁移 - Notion 模块 diff --git a/app/config.py b/app/config.py index 021aa8a..e6d8e98 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): home_assistant_base_url: str = "" home_assistant_auth_token: str = "" + home_assistant_timeout_seconds: float = 1.0 home_assistant_action_task_project_id: str = "" model_config = SettingsConfigDict( diff --git a/app/integrations/homeassistant.py b/app/integrations/homeassistant.py index 944839c..d371a17 100644 --- a/app/integrations/homeassistant.py +++ b/app/integrations/homeassistant.py @@ -1,12 +1,108 @@ -from dataclasses import dataclass +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any +from urllib import error, parse, request from app.config import Settings +logger = logging.getLogger(__name__) +SUCCESS_STATUS_CODES = {200, 201} + + +class HomeAssistantConfigError(RuntimeError): + """Raised when required Home Assistant outbound configuration is missing.""" + + +class HomeAssistantRequestError(RuntimeError): + """Raised when a Home Assistant outbound HTTP request fails.""" + @dataclass(slots=True) class HomeAssistantClient: settings: Settings + timeout_seconds: float | None = field(default=None) + + def __post_init__(self) -> None: + if self.timeout_seconds is None: + self.timeout_seconds = self.settings.home_assistant_timeout_seconds def is_configured(self) -> bool: return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) + def publish_sensor( + self, + *, + entity_id: str, + state: str, + attributes: dict[str, Any] | None = None, + ) -> None: + self._require_config() + if not entity_id: + raise ValueError("entity_id must not be empty") + + payload = { + "entity_id": entity_id, + "state": state, + "attributes": attributes or {}, + } + self._post_json(f"/api/states/{entity_id}", payload, operation="publish_sensor") + + def trigger_webhook(self, *, webhook_id: str, body: Any) -> None: + self._require_config() + if not webhook_id: + raise ValueError("webhook_id must not be empty") + + self._post_json(f"/api/webhook/{webhook_id}", body, operation="trigger_webhook") + + def _require_config(self) -> None: + if self.is_configured(): + return + raise HomeAssistantConfigError( + "Home Assistant outbound integration is not configured. " + "Set HOME_ASSISTANT_BASE_URL and HOME_ASSISTANT_AUTH_TOKEN." + ) + + def _post_json(self, path: str, payload: Any, *, operation: str) -> None: + url = self._build_url(path) + body = json.dumps(payload).encode("utf-8") + req = request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", f"Bearer {self.settings.home_assistant_auth_token}") + + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + status_code = response.getcode() + except error.HTTPError as exc: + logger.warning( + "Home Assistant outbound %s failed with HTTP %s for %s", + operation, + exc.code, + url, + ) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} failed with HTTP {exc.code}" + ) from exc + except error.URLError as exc: + logger.warning("Home Assistant outbound %s failed for %s: %s", operation, url, exc) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} failed to reach Home Assistant" + ) from exc + + if status_code not in SUCCESS_STATUS_CODES: + logger.warning( + "Home Assistant outbound %s returned unexpected status %s for %s", + operation, + status_code, + url, + ) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} returned unexpected status {status_code}" + ) + + def _build_url(self, path: str) -> str: + base_url = self.settings.home_assistant_base_url.rstrip("/") + quoted_path = parse.quote(path.lstrip("/"), safe="/") + return f"{base_url}/{quoted_path}" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index a2c878e..5c5cb9b 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -36,7 +36,8 @@ - `services/` - 业务服务层 - `integrations/` - - 外部系统适配层占位 + - 外部系统适配层 + - 当前已迁入 Home Assistant outbound adapter - `templates/` - Jinja2 模板 - `static/` @@ -76,4 +77,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确 - 不预留 Notion 相关业务流 如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 - diff --git a/docs/homeassistant-outbound.md b/docs/homeassistant-outbound.md new file mode 100644 index 0000000..a24581f --- /dev/null +++ b/docs/homeassistant-outbound.md @@ -0,0 +1,51 @@ +# Home Assistant Outbound Integration + +本文档说明当前 Python 项目中已经迁入的 Home Assistant outbound integration layer。 + +这里的 outbound 指: + +- 由当前 app 主动调用 Home Assistant + +当前不包含: + +- `/homeassistant/publish` +- Home Assistant inbound command gateway +- Home Assistant 驱动当前 app 的入站消息路由 + +## 当前已支持能力 + +当前 `app/integrations/homeassistant.py` 提供一个轻量的 `HomeAssistantClient`,已支持: + +- 发布 / 更新 sensor state + - `POST /api/states/{entity_id}` +- 触发 Home Assistant webhook + - `POST /api/webhook/{webhook_id}` + +这两项能力是按 legacy Go 中 `util/homeassistantutil/homeassistantutil.go` 的出站行为迁入的。 + +## 当前配置 + +当前 outbound adapter 依赖以下配置: + +- `HOME_ASSISTANT_BASE_URL` +- `HOME_ASSISTANT_AUTH_TOKEN` +- `HOME_ASSISTANT_TIMEOUT_SECONDS` + +如果缺少必要配置,client 会直接抛出配置错误,而不是静默跳过。 + +## 错误处理策略 + +当前策略保持保守和简单: + +- 配置缺失:抛出 `HomeAssistantConfigError` +- 参数明显非法:抛出 `ValueError` +- Home Assistant 返回非 200/201:抛出 `HomeAssistantRequestError` +- 网络请求失败:抛出 `HomeAssistantRequestError` + +当前还没有做: + +- 自动重试 +- 熔断 +- 更复杂的 backoff 策略 + +这一轮重点是先把 app -> Home Assistant 的出站契约和可复用结构迁进来。 diff --git a/tests/test_config.py b/tests/test_config.py index eb60c7b..a3349c6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,11 +4,17 @@ from app.config import Settings def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://ha.local:8123") + monkeypatch.setenv("HOME_ASSISTANT_AUTH_TOKEN", "token") + monkeypatch.setenv("HOME_ASSISTANT_TIMEOUT_SECONDS", "2.5") settings = Settings() assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db" + assert settings.home_assistant_base_url == "http://ha.local:8123" + assert settings.home_assistant_auth_token == "token" + assert settings.home_assistant_timeout_seconds == 2.5 assert settings.location_sqlite_path is not None assert settings.location_sqlite_path.name == "locationRecorder.db" assert settings.poo_sqlite_path is not None diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py new file mode 100644 index 0000000..259be39 --- /dev/null +++ b/tests/test_homeassistant.py @@ -0,0 +1,113 @@ +import json +from urllib import error + +import pytest + +from app.config import Settings +from app.integrations.homeassistant import ( + HomeAssistantClient, + HomeAssistantConfigError, + HomeAssistantRequestError, +) + + +class _FakeResponse: + def __init__(self, status_code: int): + self.status_code = status_code + + def getcode(self) -> int: + return self.status_code + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _configured_settings() -> Settings: + return Settings( + home_assistant_base_url="http://ha.local:8123", + home_assistant_auth_token="secret-token", + home_assistant_timeout_seconds=1.5, + ) + + +def test_publish_sensor_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = HomeAssistantClient( + settings=_configured_settings(), + timeout_seconds=_configured_settings().home_assistant_timeout_seconds, + ) + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(200) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + client.publish_sensor( + entity_id="sensor.test_poo_status", + state="happy", + attributes={"friendly_name": "Poo Status"}, + ) + + assert captured["url"] == "http://ha.local:8123/api/states/sensor.test_poo_status" + assert captured["timeout"] == pytest.approx(1.5) + assert captured["authorization"] == "Bearer secret-token" + assert captured["content_type"] == "application/json" + assert captured["body"] == { + "entity_id": "sensor.test_poo_status", + "state": "happy", + "attributes": {"friendly_name": "Poo Status"}, + } + + +def test_trigger_webhook_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = HomeAssistantClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(201) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + client.trigger_webhook(webhook_id="poo-status", body={"status": "done"}) + + assert captured["url"] == "http://ha.local:8123/api/webhook/poo-status" + assert captured["body"] == {"status": "done"} + + +def test_homeassistant_client_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None: + client = HomeAssistantClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + raise error.HTTPError(req.full_url, 500, "boom", hdrs=None, fp=None) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + with pytest.raises(HomeAssistantRequestError, match="HTTP 500"): + client.publish_sensor(entity_id="sensor.test_status", state="bad") + + +def test_homeassistant_client_raises_when_not_configured() -> None: + client = HomeAssistantClient(settings=Settings()) + + with pytest.raises(HomeAssistantConfigError, match="not configured"): + client.publish_sensor(entity_id="sensor.test_status", state="ok") + + +def test_homeassistant_client_raises_on_invalid_arguments() -> None: + client = HomeAssistantClient(settings=_configured_settings()) + + with pytest.raises(ValueError, match="entity_id"): + client.publish_sensor(entity_id="", state="ok") + + with pytest.raises(ValueError, match="webhook_id"): + client.trigger_webhook(webhook_id="", body={})