Add Home Assistant outbound adapter
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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 模块
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 骨架中保留它。
|
||||||
|
|
||||||
|
|||||||
@@ -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 的出站契约和可复用结构迁进来。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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={})
|
||||||
Reference in New Issue
Block a user