feat: Implement IoT logging endpoint and related models/schemas

This commit is contained in:
2026-05-11 14:39:03 +08:00
parent ce6734d906
commit 255a6f0f71
7 changed files with 212 additions and 1 deletions
@@ -0,0 +1,69 @@
"""create_iot_tables
Revision ID: f1e2d3c4b5a6
Revises: 670c74d20f60
Create Date: 2026-05-11 00:01:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f1e2d3c4b5a6"
down_revision = "670c74d20f60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"iot_device",
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("node", sa.String(length=128), nullable=True),
sa.Column(
"create_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"update_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("device_id"),
)
op.create_table(
"iot_reading",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("sensor_type", sa.String(length=64), nullable=False),
sa.Column("value", sa.String(length=128), nullable=False),
sa.Column(
"create_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"update_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["device_id"], ["iot_device.device_id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("iot_reading")
op.drop_table("iot_device")
# ### end Alembic commands ###
+10 -1
View File
@@ -1,7 +1,15 @@
from fastapi import APIRouter
from app.api import api_messages
from app.api.endpoints import auth, humidity, pets, pressure, temperature, users
from app.api.endpoints import (
auth,
humidity,
pets,
pressure,
temperature,
users,
iot,
)
auth_router = APIRouter()
auth_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -38,3 +46,4 @@ api_router.include_router(pressure.router, prefix="/pressure", tags=["pressure"]
api_router.include_router(
temperature.router, prefix="/temperature", tags=["temperature"]
)
api_router.include_router(iot.router, prefix="/iot", tags=["iot"])
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.models import IoTDevice, IoTReading
from app.schemas.requests import IoTLogRequest
from app.schemas.responses import IoTLogResponse
router = APIRouter()
@router.post(
"/log",
response_model=IoTLogResponse,
status_code=status.HTTP_201_CREATED,
description="Log batch IoT device sensor readings received from MQTT",
)
async def log_iot_readings(
data: IoTLogRequest, session: AsyncSession = Depends(deps.get_session)
) -> dict:
total = 0
for device in data.devices:
existing = await session.scalar(
select(IoTDevice).where(IoTDevice.device_id == device.device_id)
)
if existing is None:
new_dev = IoTDevice(device_id=device.device_id)
session.add(new_dev)
for r in device.readings:
new_reading = IoTReading(
device_id=device.device_id, sensor_type=r.sensor_type, value=r.value
)
session.add(new_reading)
total += 1
await session.commit()
return {"logged_count": total}
+21
View File
@@ -86,3 +86,24 @@ class Pressure(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
pressure: Mapped[str] = mapped_column(String(10), nullable=False)
class IoTDevice(Base):
__tablename__ = "iot_device"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
node: Mapped[str] = mapped_column(String(128), nullable=True)
readings: Mapped[list["IoTReading"]] = relationship(back_populates="device")
class IoTReading(Base):
__tablename__ = "iot_reading"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
device_id: Mapped[str] = mapped_column(
ForeignKey("iot_device.device_id", ondelete="CASCADE"), nullable=False
)
sensor_type: Mapped[str] = mapped_column(String(64), nullable=False)
value: Mapped[str] = mapped_column(String(128), nullable=False)
# reading_time is optional — use create_time from Base for recorded time
device: Mapped["IoTDevice"] = relationship(back_populates="readings")
+16
View File
@@ -58,3 +58,19 @@ class MqttCreateRequest(BaseRequest):
# "id": "000617968C1EEAD9DB5B00000CFD0E24",
# "flags": {"retain": false, "dup": false},
# }
class SensorReading(BaseRequest):
sensor_type: str
value: str
timestamp: int | None = None
class DeviceData(BaseRequest):
device_id: str
readings: list[SensorReading]
class IoTLogRequest(BaseRequest):
devices: list[DeviceData]
received_at: int | None = None
+12
View File
@@ -42,3 +42,15 @@ class PressureResponse(BaseResponse):
id: int
pressure: str
update_time: datetime
class IoTReadingResponse(BaseResponse):
id: int
device_id: str
sensor_type: str
value: str
update_time: datetime
class IoTLogResponse(BaseResponse):
logged_count: int
+44
View File
@@ -0,0 +1,44 @@
import pytest
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.models import IoTReading
@pytest.mark.asyncio(loop_scope="session")
async def test_log_iot_readings_single_device(client: AsyncClient, session: AsyncSession) -> None:
payload = {
"devices": [
{"device_id": "dev-1", "readings": [{"sensor_type": "temperature", "value": "25.5"}]}
]
}
response = await client.post(app.url_path_for("log_iot_readings"), json=payload)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["logged_count"] == 1
@pytest.mark.asyncio(loop_scope="session")
async def test_log_iot_readings_persists_multiple(client: AsyncClient, session: AsyncSession) -> None:
payload = {
"devices": [
{"device_id": "dev-2", "readings": [
{"sensor_type": "temperature", "value": "21.0"},
{"sensor_type": "humidity", "value": "60%"}
]},
{"device_id": "dev-3", "readings": [
{"sensor_type": "pressure", "value": "1001"}
]},
]
}
response = await client.post(app.url_path_for("log_iot_readings"), json=payload)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["logged_count"] == 3
rows = await session.scalars(select(IoTReading))
all_readings = list(rows.all())
assert len(all_readings) >= 3