mirror of
https://github.com/furyhawk/listen.git
synced 2026-07-21 00:45:36 +00:00
feat: Implement IoT logging endpoint and related models/schemas
This commit is contained in:
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user