From e334df992f8b77e77eb213245ffa1b4d2ed27401 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 10:42:35 +0200 Subject: [PATCH] Add Home Assistant inbound gateway --- README.md | 8 +- app/api/routes/homeassistant.py | 49 ++++++++ app/api/routes/location.py | 10 +- app/main.py | 2 + app/schemas/homeassistant.py | 9 ++ app/services/homeassistant_inbound.py | 35 ++++++ docs/architecture-overview.md | 1 + docs/homeassistant-inbound.md | 58 ++++++++++ openapi/openapi.json | 19 +++ openapi/openapi.yaml | 12 ++ tests/conftest.py | 20 ++++ tests/test_homeassistant_inbound.py | 160 ++++++++++++++++++++++++++ tests/test_location.py | 26 +---- 13 files changed, 380 insertions(+), 29 deletions(-) create mode 100644 app/api/routes/homeassistant.py create mode 100644 app/schemas/homeassistant.py create mode 100644 app/services/homeassistant_inbound.py create mode 100644 docs/homeassistant-inbound.md create mode 100644 tests/test_homeassistant_inbound.py diff --git a/README.md b/README.md index 328fe20..9cef85c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 极简 server-side templates - location recorder 第一版迁移 - Home Assistant outbound integration layer +- Home Assistant inbound gateway 第一版 - pytest 测试基础 - OpenAPI 导出脚本 - Docker / Compose 基础骨架 @@ -19,10 +20,15 @@ 当前阶段明确不包含: - TickTick 业务逻辑迁移 -- Home Assistant inbound command gateway - poo records 业务迁移 - Notion 模块 +当前 Home Assistant inbound gateway 仅接回第一版: + +- 已支持 `location_recorder / record` +- 尚未接回 TickTick 路径 +- 尚未接回 poo recorder 路径 + Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 旧 Go 代码位置: diff --git a/app/api/routes/homeassistant.py b/app/api/routes/homeassistant.py new file mode 100644 index 0000000..396c527 --- /dev/null +++ b/app/api/routes/homeassistant.py @@ -0,0 +1,49 @@ +import json +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, Response +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.dependencies import get_db +from app.schemas.homeassistant import HomeAssistantPublishEnvelope +from app.services.homeassistant_inbound import ( + UnsupportedHomeAssistantMessage, + handle_homeassistant_message, +) + +router = APIRouter(tags=["homeassistant"]) +logger = logging.getLogger(__name__) +BAD_REQUEST_MESSAGE = "bad request" +INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" + + +@router.post("/homeassistant/publish") +async def publish_from_homeassistant( + request: Request, db: Session = Depends(get_db) +) -> Response: + try: + raw_payload = await request.body() + data = json.loads(raw_payload) + envelope = HomeAssistantPublishEnvelope.model_validate(data) + handle_homeassistant_message(db, envelope) + except json.JSONDecodeError as exc: + logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except ValidationError as exc: + logger.warning( + "Rejected Home Assistant publish request due to validation failure: %s", exc + ) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except UnsupportedHomeAssistantMessage as exc: + logger.warning("Home Assistant publish target/action unsupported: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except ValueError as exc: + logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + + return Response(status_code=status.HTTP_200_OK) diff --git a/app/api/routes/location.py b/app/api/routes/location.py index 6c03bf4..5b87503 100644 --- a/app/api/routes/location.py +++ b/app/api/routes/location.py @@ -1,7 +1,7 @@ import json import logging -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, status from fastapi.responses import PlainTextResponse, Response from pydantic import ValidationError from sqlalchemy.orm import Session @@ -24,12 +24,12 @@ async def create_location_record(request: Request, db: Session = Depends(get_db) record_location(db, payload) except json.JSONDecodeError as exc: logger.warning("Rejected location request due to invalid JSON: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) except ValidationError as exc: logger.warning("Rejected location request due to payload validation failure: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) except ValueError as exc: logger.warning("Rejected location request due to invalid numeric input: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) - return Response(status_code=200) + return Response(status_code=status.HTTP_200_OK) diff --git a/app/main.py b/app/main.py index e1c3ee5..b77fe37 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from app import models # noqa: F401 from app.api.routes import pages, status +from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.config import get_settings from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db @@ -54,6 +55,7 @@ def create_app() -> FastAPI: app.include_router(status.router) app.include_router(pages.router) + app.include_router(homeassistant_router) app.include_router(location_router) return app diff --git a/app/schemas/homeassistant.py b/app/schemas/homeassistant.py new file mode 100644 index 0000000..da034d6 --- /dev/null +++ b/app/schemas/homeassistant.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + + +class HomeAssistantPublishEnvelope(BaseModel): + target: str + action: str + content: str + + model_config = ConfigDict(extra="forbid") diff --git a/app/services/homeassistant_inbound.py b/app/services/homeassistant_inbound.py new file mode 100644 index 0000000..eead1f9 --- /dev/null +++ b/app/services/homeassistant_inbound.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from sqlalchemy.orm import Session + +from app.schemas.homeassistant import HomeAssistantPublishEnvelope +from app.schemas.location import LocationRecordRequest +from app.services.location import record_location + + +class UnsupportedHomeAssistantMessage(RuntimeError): + """Raised when the inbound gateway receives a target/action that is not supported yet.""" + + +def handle_homeassistant_message( + session: Session, envelope: HomeAssistantPublishEnvelope +) -> None: + if envelope.target == "location_recorder": + _handle_location_message(session, envelope) + return + + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + + +def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnvelope) -> None: + if envelope.action != "record": + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + + content = json.loads(envelope.content.replace("'", '"')) + payload = LocationRecordRequest.model_validate(content) + record_location(session, payload) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 5c5cb9b..984b701 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -29,6 +29,7 @@ - 通用依赖注入 - `api/` - HTTP routes + - 当前已迁入 `POST /homeassistant/publish` 第一版入口 - `models/` - SQLAlchemy models - `schemas/` diff --git a/docs/homeassistant-inbound.md b/docs/homeassistant-inbound.md new file mode 100644 index 0000000..4a10305 --- /dev/null +++ b/docs/homeassistant-inbound.md @@ -0,0 +1,58 @@ +# Home Assistant Inbound Gateway + +本文档说明当前 Python 项目中已经迁入的 Home Assistant inbound gateway 第一版。 + +这里的 inbound 指: + +- Home Assistant 主动调用当前 app 的入口 + +当前已恢复的入口是: + +- `POST /homeassistant/publish` + +## Request Envelope + +当前沿用 legacy Go 的 envelope 形状: + +```json +{ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'alice', 'latitude': '1.23', 'longitude': '4.56'}" +} +``` + +说明: + +- `target`、`action`、`content` 均为必填 +- unknown field 会被拒绝 +- `content` 当前仍兼容 legacy 常见的单引号 JSON 字符串风格 + +## 当前已支持的 Target / Action + +当前只接回最小可用路径: + +- `location_recorder / record` + +它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。 + +## 当前尚未接回 + +以下 legacy 路径在当前阶段还没有迁入: + +- `poo_recorder / get_latest` +- `ticktick / create_action_task` +- 其他未定义 target/action + +这些请求当前会返回: + +- `500 internal server error` + +## 错误处理 + +当前策略保持简洁: + +- envelope 非法、缺字段、unknown field、`content` 非法:返回 `400 bad request` +- target/action 当前未迁入:返回 `500 internal server error` + +对 caller 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。 diff --git a/openapi/openapi.json b/openapi/openapi.json index 8f02aa2..bf895d7 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -48,6 +48,25 @@ } } }, + "/homeassistant/publish": { + "post": { + "tags": [ + "homeassistant" + ], + "summary": "Publish From Homeassistant", + "operationId": "publish_from_homeassistant_homeassistant_publish_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/location/record": { "post": { "tags": [ diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f0db5ad..8bf3714 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -31,6 +31,18 @@ paths: text/html: schema: type: string + /homeassistant/publish: + post: + tags: + - homeassistant + summary: Publish From Homeassistant + operationId: publish_from_homeassistant_homeassistant_publish_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} /location/record: post: tags: diff --git a/tests/conftest.py b/tests/conftest.py index 6df20d7..4fd9237 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,10 @@ import pytest from alembic import command from alembic.config import Config from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import app.db as app_db from app.config import get_settings from app.main import create_app @@ -52,3 +55,20 @@ def app(ready_location_database): def client(app): with TestClient(app) as test_client: yield test_client + + +@pytest.fixture +def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): + database_url = ready_location_database["location_url"] + + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + monkeypatch.setattr(app_db, "engine", engine) + monkeypatch.setattr(app_db, "SessionLocal", session_local) + + fastapi_app = create_app() + with TestClient(fastapi_app) as client: + yield client, engine + + engine.dispose() diff --git a/tests/test_homeassistant_inbound.py b/tests/test_homeassistant_inbound.py new file mode 100644 index 0000000..7adf782 --- /dev/null +++ b/tests/test_homeassistant_inbound.py @@ -0,0 +1,160 @@ +from sqlalchemy import text + + +def test_homeassistant_publish_records_location(location_client) -> None: + client, engine = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'tianyu', 'latitude': '1.23', 'longitude': '4.56'}", + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT person, latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.person == "tianyu" + assert row.latitude == 1.23 + assert row.longitude == 4.56 + assert row.altitude == 0.0 + + +def test_homeassistant_publish_records_location_with_altitude(location_client) -> None: + client, engine = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": ( + "{'person': 'tianyu-alt', 'latitude': '1.23', " + "'longitude': '4.56', 'altitude': '7.89'}" + ), + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT person, latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.person == "tianyu-alt" + assert row.latitude == 1.23 + assert row.longitude == 4.56 + assert row.altitude == 7.89 + + +def test_homeassistant_publish_rejects_invalid_envelope(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{}", + "extra": "not-allowed", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "extra" not in response.text + + +def test_homeassistant_publish_rejects_invalid_json_body(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + content='{"target": "location_recorder", "action": "record", "content": ', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + + +def test_homeassistant_publish_rejects_missing_content(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "content" not in response.text + + +def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action( + location_client, +) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "unknown_action", + "content": "{}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_rejects_invalid_location_content(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'tianyu', 'latitude': 'bad-lat', 'longitude': '4.56'}", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "bad-lat" not in response.text diff --git a/tests/test_location.py b/tests/test_location.py index 47dee9f..f18696b 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -8,7 +8,7 @@ from alembic.config import Config from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker -import app.db +import app.db as app_db from app.main import create_app from scripts.location_db_adopt import ( EXPECTED_USER_VERSION, @@ -23,26 +23,6 @@ def _make_alembic_config(database_url: str) -> Config: config.set_main_option("sqlalchemy.url", database_url) return config - -@pytest.fixture -def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): - database_url = ready_location_database["location_url"] - - engine = create_engine(database_url, connect_args={"check_same_thread": False}) - session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) - - monkeypatch.setattr(app.db, "engine", engine) - monkeypatch.setattr(app.db, "SessionLocal", session_local) - - from fastapi.testclient import TestClient - - fastapi_app = create_app() - with TestClient(fastapi_app) as client: - yield client, engine - - engine.dispose() - - def test_location_record_endpoint_writes_row(location_client) -> None: client, engine = location_client @@ -243,8 +223,8 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( engine = create_engine(database_url, connect_args={"check_same_thread": False}) session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) - monkeypatch.setattr(app.db, "engine", engine) - monkeypatch.setattr(app.db, "SessionLocal", session_local) + monkeypatch.setattr(app_db, "engine", engine) + monkeypatch.setattr(app_db, "SessionLocal", session_local) from fastapi.testclient import TestClient