Add Home Assistant inbound gateway

This commit is contained in:
2026-04-20 10:42:35 +02:00
parent 151ad46275
commit e334df992f
13 changed files with 380 additions and 29 deletions
+7 -1
View File
@@ -12,6 +12,7 @@
- 极简 server-side templates - 极简 server-side templates
- location recorder 第一版迁移 - location recorder 第一版迁移
- Home Assistant outbound integration layer - Home Assistant outbound integration layer
- Home Assistant inbound gateway 第一版
- pytest 测试基础 - pytest 测试基础
- OpenAPI 导出脚本 - OpenAPI 导出脚本
- Docker / Compose 基础骨架 - Docker / Compose 基础骨架
@@ -19,10 +20,15 @@
当前阶段明确不包含: 当前阶段明确不包含:
- TickTick 业务逻辑迁移 - TickTick 业务逻辑迁移
- Home Assistant inbound command gateway
- poo records 业务迁移 - poo records 业务迁移
- Notion 模块 - Notion 模块
当前 Home Assistant inbound gateway 仅接回第一版:
- 已支持 `location_recorder / record`
- 尚未接回 TickTick 路径
- 尚未接回 poo recorder 路径
Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。
旧 Go 代码位置: 旧 Go 代码位置:
+49
View File
@@ -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)
+5 -5
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, Response from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.orm import Session 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) record_location(db, payload)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.warning("Rejected location request due to invalid JSON: %s", 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: except ValidationError as exc:
logger.warning("Rejected location request due to payload validation failure: %s", 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: except ValueError as exc:
logger.warning("Rejected location request due to invalid numeric input: %s", 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)
+2
View File
@@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes import pages, status 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.api.routes.location import router as location_router
from app.config import get_settings from app.config import get_settings
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db 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(status.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(homeassistant_router)
app.include_router(location_router) app.include_router(location_router)
return app return app
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class HomeAssistantPublishEnvelope(BaseModel):
target: str
action: str
content: str
model_config = ConfigDict(extra="forbid")
+35
View File
@@ -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)
+1
View File
@@ -29,6 +29,7 @@
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- `schemas/` - `schemas/`
+58
View File
@@ -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 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。
+19
View File
@@ -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": { "/location/record": {
"post": { "post": {
"tags": [ "tags": [
+12
View File
@@ -31,6 +31,18 @@ paths:
text/html: text/html:
schema: schema:
type: string 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: /location/record:
post: post:
tags: tags:
+20
View File
@@ -4,7 +4,10 @@ import pytest
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi.testclient import TestClient 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.config import get_settings
from app.main import create_app from app.main import create_app
@@ -52,3 +55,20 @@ def app(ready_location_database):
def client(app): def client(app):
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield 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()
+160
View File
@@ -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
+3 -23
View File
@@ -8,7 +8,7 @@ from alembic.config import Config
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
import app.db import app.db as app_db
from app.main import create_app from app.main import create_app
from scripts.location_db_adopt import ( from scripts.location_db_adopt import (
EXPECTED_USER_VERSION, EXPECTED_USER_VERSION,
@@ -23,26 +23,6 @@ def _make_alembic_config(database_url: str) -> Config:
config.set_main_option("sqlalchemy.url", database_url) config.set_main_option("sqlalchemy.url", database_url)
return config 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: def test_location_record_endpoint_writes_row(location_client) -> None:
client, engine = location_client 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}) engine = create_engine(database_url, connect_args={"check_same_thread": False})
session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False)
monkeypatch.setattr(app.db, "engine", engine) monkeypatch.setattr(app_db, "engine", engine)
monkeypatch.setattr(app.db, "SessionLocal", session_local) monkeypatch.setattr(app_db, "SessionLocal", session_local)
from fastapi.testclient import TestClient from fastapi.testclient import TestClient