Add Home Assistant outbound adapter

This commit is contained in:
2026-04-20 10:11:02 +02:00
parent eb487ccb46
commit 151ad46275
8 changed files with 273 additions and 4 deletions
+1
View File
@@ -11,4 +11,5 @@ TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback
TICKTICK_TOKEN= TICKTICK_TOKEN=
HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_BASE_URL=http://localhost:8123
HOME_ASSISTANT_AUTH_TOKEN= HOME_ASSISTANT_AUTH_TOKEN=
HOME_ASSISTANT_TIMEOUT_SECONDS=1.0
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
+2 -1
View File
@@ -11,6 +11,7 @@
- SQLite + SQLAlchemy + Alembic 基础设施 - SQLite + SQLAlchemy + Alembic 基础设施
- 极简 server-side templates - 极简 server-side templates
- location recorder 第一版迁移 - location recorder 第一版迁移
- Home Assistant outbound integration layer
- pytest 测试基础 - pytest 测试基础
- OpenAPI 导出脚本 - OpenAPI 导出脚本
- Docker / Compose 基础骨架 - Docker / Compose 基础骨架
@@ -18,7 +19,7 @@
当前阶段明确不包含: 当前阶段明确不包含:
- TickTick 业务逻辑迁移 - TickTick 业务逻辑迁移
- Home Assistant 业务逻辑迁移 - Home Assistant inbound command gateway
- poo records 业务迁移 - poo records 业务迁移
- Notion 模块 - Notion 模块
+1
View File
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
home_assistant_base_url: str = "" home_assistant_base_url: str = ""
home_assistant_auth_token: str = "" home_assistant_auth_token: str = ""
home_assistant_timeout_seconds: float = 1.0
home_assistant_action_task_project_id: str = "" home_assistant_action_task_project_id: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
+97 -1
View File
@@ -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 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) @dataclass(slots=True)
class HomeAssistantClient: class HomeAssistantClient:
settings: Settings 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: def is_configured(self) -> bool:
return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) 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}"
+2 -2
View File
@@ -36,7 +36,8 @@
- `services/` - `services/`
- 业务服务层 - 业务服务层
- `integrations/` - `integrations/`
- 外部系统适配层占位 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter
- `templates/` - `templates/`
- Jinja2 模板 - Jinja2 模板
- `static/` - `static/`
@@ -76,4 +77,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确
- 不预留 Notion 相关业务流 - 不预留 Notion 相关业务流
如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。
+51
View File
@@ -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 的出站契约和可复用结构迁进来。
+6
View File
@@ -4,11 +4,17 @@ from app.config import Settings
def test_settings_support_two_independent_database_urls(monkeypatch) -> None: def test_settings_support_two_independent_database_urls(monkeypatch) -> None:
monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db")
monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.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() settings = Settings()
assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" assert settings.location_database_url == "sqlite:///./data/locationRecorder.db"
assert settings.poo_database_url == "sqlite:///./data/pooRecorder.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 is not None
assert settings.location_sqlite_path.name == "locationRecorder.db" assert settings.location_sqlite_path.name == "locationRecorder.db"
assert settings.poo_sqlite_path is not None assert settings.poo_sqlite_path is not None
+113
View File
@@ -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={})