M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test)

This commit is contained in:
2026-06-22 13:37:48 +02:00
parent 49d15d8ffe
commit 702a1cec00
7 changed files with 3060 additions and 1 deletions
+433
View File
@@ -0,0 +1,433 @@
"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).
All endpoints are under /api/modbus, require an authenticated session, and
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
non-empty X-CSRF-Token header.
Deletion safety (red-line):
DELETE /api/modbus/devices/{uuid} explicitly queries the application
layer for existing modbus_reading rows before deleting. If any exist
the endpoint returns 409 and suggests disabling the device instead.
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does
not enforce foreign-key constraints at runtime unless
PRAGMA foreign_keys=ON is set per connection (and it is not in this
project — see M5-T02 review note OBS-1).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.integrations.modbus import driver as modbus_driver
from app.integrations.modbus.driver import ModbusDriverError
from app.integrations.modbus.profiles import (
ProfileNotFoundError,
decode as decode_profile,
list_profiles,
load_profile,
)
from app.models.modbus import ModbusDevice, ModbusReading
from app.schemas.modbus import (
MetricInfo,
ModbusDeviceCreate,
ModbusDeviceListResponse,
ModbusDeviceResponse,
ModbusDeviceUpdate,
ModbusLatestResponse,
ModbusMetricsResponse,
ModbusProfilesResponse,
ModbusReadingResponse,
ModbusReadingsResponse,
ModbusTestReadResponse,
ProfileSummary,
)
from app.services.auth import AuthenticatedSession
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/modbus", tags=["api-modbus"])
# Maximum number of readings that can be returned per request.
_READINGS_LIMIT_MAX = 5000
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
"""Return the device with the given UUID or raise 404."""
device = db.execute(
select(ModbusDevice).where(ModbusDevice.uuid == uuid)
).scalar_one_or_none()
if device is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device '{uuid}' not found.",
)
return device
def _label_from_key(key: str) -> str:
"""Derive a human-readable label from a metric key.
Example: ``"active_power"`` → ``"Active Power"``
"""
return key.replace("_", " ").title()
# ---------------------------------------------------------------------------
# GET /api/modbus/profiles
# ---------------------------------------------------------------------------
@router.get("/profiles", response_model=ModbusProfilesResponse)
def get_profiles(
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusProfilesResponse:
"""List all available Modbus YAML profiles (name + description).
Intended for the front-end's device-creation profile drop-down.
"""
pairs = list_profiles()
return ModbusProfilesResponse(
profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
)
# ---------------------------------------------------------------------------
# Device CRUD
# ---------------------------------------------------------------------------
@router.get("/devices", response_model=ModbusDeviceListResponse)
def list_devices(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceListResponse:
"""Return all Modbus devices (no pagination — device counts stay small)."""
devices = db.execute(select(ModbusDevice)).scalars().all()
items = [ModbusDeviceResponse.model_validate(d) for d in devices]
return ModbusDeviceListResponse(items=items, total=len(items))
@router.post(
"/devices",
response_model=ModbusDeviceResponse,
status_code=status.HTTP_201_CREATED,
)
def create_device(
body: ModbusDeviceCreate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
"""Create a new Modbus device.
- Validates that the referenced ``profile`` exists; returns 422 if not.
- Returns 201 with the created device on success.
"""
# Validate profile exists (422 if not)
try:
load_profile(body.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Profile '{body.profile}' does not exist.",
)
now = datetime.now(UTC)
device = ModbusDevice(
friendly_name=body.friendly_name,
transport=body.transport,
host=body.host,
port=body.port,
unit_id=body.unit_id,
profile=body.profile,
poll_interval_s=body.poll_interval_s,
enabled=body.enabled,
created_at=now,
updated_at=now,
)
db.add(device)
db.commit()
db.refresh(device)
logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
return ModbusDeviceResponse.model_validate(device)
@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
def get_device(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceResponse:
"""Return a single Modbus device by UUID."""
device = _get_device_or_404(db, uuid)
return ModbusDeviceResponse.model_validate(device)
@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
def patch_device(
uuid: str,
body: ModbusDeviceUpdate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
"""Partially update a Modbus device (including enable/disable).
Only fields explicitly provided in the request body are updated.
Providing ``profile`` triggers a profile-existence check (422 if unknown).
"""
device = _get_device_or_404(db, uuid)
update_data = body.model_dump(exclude_none=True)
# If profile is being changed, validate it exists.
if "profile" in update_data:
try:
load_profile(update_data["profile"])
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Profile '{update_data['profile']}' does not exist.",
)
for field, value in update_data.items():
setattr(device, field, value)
device.updated_at = datetime.now(UTC)
db.commit()
db.refresh(device)
logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
return ModbusDeviceResponse.model_validate(device)
@router.delete(
"/devices/{uuid}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_device(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""Delete a Modbus device.
Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead.
**Application-layer safety**: the check is performed via an explicit
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
"""
device = _get_device_or_404(db, uuid)
# Application-layer guard: refuse deletion if any readings exist.
# We do NOT rely on SQLite FK RESTRICT — it is not enforced at runtime
# without PRAGMA foreign_keys=ON, which is not set in this project.
reading_count: int = db.execute(
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
).scalar_one()
if reading_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
"deleted. Disable the device (set enabled=false) instead to stop polling "
"without losing historical data."
),
)
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
db.delete(device)
db.commit()
# ---------------------------------------------------------------------------
# Readings endpoints
# ---------------------------------------------------------------------------
@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
def get_latest_reading(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusLatestResponse:
"""Return the most recent reading for a device.
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
"payload": null}`` (200, not 404) so the front-end can distinguish
"device exists but has no data" from "device not found".
"""
device = _get_device_or_404(db, uuid)
row = db.execute(
select(ModbusReading)
.where(ModbusReading.device_id == device.id)
.order_by(ModbusReading.recorded_at.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return ModbusLatestResponse(found=False, recorded_at=None, payload=None)
return ModbusLatestResponse(
found=True,
recorded_at=row.recorded_at,
payload=row.payload,
)
@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
def get_readings(
uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusReadingsResponse:
"""Return time-range readings for a device.
Results are ordered by ``recorded_at`` ascending and capped by ``limit``
(max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
The query uses the ``(device_id, recorded_at)`` composite index for
efficient time-window scans.
Query parameters:
- ``start``: inclusive lower bound (ISO8601 datetime)
- ``end``: inclusive upper bound (ISO8601 datetime)
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
"""
device = _get_device_or_404(db, uuid)
stmt = (
select(ModbusReading)
.where(ModbusReading.device_id == device.id)
.order_by(ModbusReading.recorded_at)
.limit(limit)
)
if start is not None:
stmt = stmt.where(ModbusReading.recorded_at >= start)
if end is not None:
stmt = stmt.where(ModbusReading.recorded_at <= end)
rows = db.execute(stmt).scalars().all()
items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
return ModbusReadingsResponse(items=items)
# ---------------------------------------------------------------------------
# Metrics endpoint
# ---------------------------------------------------------------------------
@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
def get_metrics(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusMetricsResponse:
"""Return the metric catalogue for a device's profile.
Each entry has ``key``, ``label`` (derived from key if the profile has
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
This is the authoritative metadata source for front-end card labels and
chart axis labels.
"""
device = _get_device_or_404(db, uuid)
try:
profile = load_profile(device.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Device profile '{device.profile}' could not be loaded.",
)
metrics = [
MetricInfo(
key=m.key,
label=_label_from_key(m.key),
unit=m.unit,
device_class=m.device_class,
)
for m in profile.metrics
]
return ModbusMetricsResponse(profile=profile.name, metrics=metrics)
# ---------------------------------------------------------------------------
# Test-read endpoint
# ---------------------------------------------------------------------------
@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
def test_read(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusTestReadResponse:
"""Immediately read and decode the device once without persisting any data.
Useful for validating gateway connectivity and Modbus address settings
before relying on the background polling job.
This is a write-class endpoint (it initiates network I/O on demand) and
therefore requires a CSRF token. The response payload is never stored.
"""
device = _get_device_or_404(db, uuid)
try:
profile = load_profile(device.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Device profile '{device.profile}' could not be loaded.",
)
try:
registers = modbus_driver.read_blocks(
device.host,
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
)
payload: dict[str, Any] = decode_profile(profile, registers)
return ModbusTestReadResponse(ok=True, payload=payload)
except ModbusDriverError as exc:
logger.warning(
"Test read failed for device %r (uuid=%s): %s",
device.friendly_name,
uuid,
exc,
)
return ModbusTestReadResponse(ok=False, error=str(exc))
except Exception as exc: # noqa: BLE001
logger.warning(
"Unexpected error during test read for device %r (uuid=%s): %s",
device.friendly_name,
uuid,
exc,
)
return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router
from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router
from app.api.routes import status
from app.db import get_session_local
@@ -125,6 +126,7 @@ def create_app() -> FastAPI:
app.include_router(status.router)
app.include_router(api_config_router)
app.include_router(api_data_router)
app.include_router(api_modbus_router)
app.include_router(api_session_router)
app.include_router(homeassistant_router)
app.include_router(location_router)
+154
View File
@@ -0,0 +1,154 @@
"""Pydantic schemas for the Modbus device CRUD + readings + metrics API (M5-T05)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Device schemas
# ---------------------------------------------------------------------------
class ModbusDeviceCreate(BaseModel):
"""Request body for POST /api/modbus/devices."""
friendly_name: str = Field(..., min_length=1, max_length=255)
transport: str = Field(default="tcp", max_length=16)
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(default=502, ge=1, le=65535)
unit_id: int = Field(default=1, ge=0, le=247)
profile: str = Field(..., min_length=1, max_length=64)
poll_interval_s: int = Field(default=5, ge=1, le=3600)
enabled: bool = Field(default=True)
class ModbusDeviceUpdate(BaseModel):
"""Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."""
friendly_name: str | None = Field(default=None, min_length=1, max_length=255)
transport: str | None = Field(default=None, max_length=16)
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
unit_id: int | None = Field(default=None, ge=0, le=247)
profile: str | None = Field(default=None, min_length=1, max_length=64)
poll_interval_s: int | None = Field(default=None, ge=1, le=3600)
enabled: bool | None = None
class ModbusDeviceResponse(BaseModel):
"""Response schema for a single Modbus device."""
uuid: str
friendly_name: str
transport: str
host: str
port: int
unit_id: int
profile: str
poll_interval_s: int
enabled: bool
last_poll_at: datetime | None
last_poll_ok: bool | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ModbusDeviceListResponse(BaseModel):
"""Response schema for listing Modbus devices."""
items: list[ModbusDeviceResponse]
total: int
# ---------------------------------------------------------------------------
# Reading schemas
# ---------------------------------------------------------------------------
class ModbusReadingResponse(BaseModel):
"""A single reading row: timestamp + decoded payload."""
recorded_at: datetime
payload: dict[str, Any]
model_config = {"from_attributes": True}
class ModbusReadingsResponse(BaseModel):
"""Response schema for the readings time-range endpoint."""
items: list[ModbusReadingResponse]
class ModbusLatestResponse(BaseModel):
"""Response for the /latest endpoint.
``found`` is False and ``recorded_at``/``payload`` are None when the device
has no readings yet. Callers should check ``found`` before using the values.
"""
found: bool
recorded_at: datetime | None
payload: dict[str, Any] | None
# ---------------------------------------------------------------------------
# Metrics / profile schemas
# ---------------------------------------------------------------------------
class MetricInfo(BaseModel):
"""Metadata for a single measurable quantity in a device's profile."""
key: str
label: str
unit: str
device_class: str
class ModbusMetricsResponse(BaseModel):
"""Response schema for GET /api/modbus/devices/{uuid}/metrics."""
profile: str
metrics: list[MetricInfo]
# ---------------------------------------------------------------------------
# Profile list schema
# ---------------------------------------------------------------------------
class ProfileSummary(BaseModel):
"""One entry in the GET /api/modbus/profiles response."""
name: str
description: str
class ModbusProfilesResponse(BaseModel):
"""Response schema for GET /api/modbus/profiles."""
profiles: list[ProfileSummary]
# ---------------------------------------------------------------------------
# Test-read schema
# ---------------------------------------------------------------------------
class ModbusTestReadResponse(BaseModel):
"""Response for POST /api/modbus/devices/{uuid}/test.
On success ``ok=True`` and ``payload`` contains the decoded values.
On failure ``ok=False`` and ``error`` describes the problem.
"""
ok: bool
payload: dict[str, Any] | None = None
error: str | None = None
+1 -1
View File
@@ -345,7 +345,7 @@ Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。
### M5-T05 — Modbus JSON APIdevice CRUD + readings + metrics + test
- **Status**: `todo` · **Depends**: M5-T02
- **Status**: `done` · **Depends**: M5-T02
- **Context**: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
- **Files**: `create app/api/routes/api/modbus.py``app/schemas/modbus.py``modify app/main.py`(注册路由);`create tests/test_api_modbus.py`
- **Steps**:
+981
View File
@@ -625,6 +625,502 @@
}
}
},
"/api/modbus/profiles": {
"get": {
"tags": [
"api-modbus"
],
"summary": "Get Profiles",
"description": "List all available Modbus YAML profiles (name + description).\n\nIntended for the front-end's device-creation profile drop-down.",
"operationId": "get_profiles_api_modbus_profiles_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusProfilesResponse"
}
}
}
}
}
}
},
"/api/modbus/devices": {
"get": {
"tags": [
"api-modbus"
],
"summary": "List Devices",
"description": "Return all Modbus devices (no pagination — device counts stay small).",
"operationId": "list_devices_api_modbus_devices_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"api-modbus"
],
"summary": "Create Device",
"description": "Create a new Modbus device.\n\n- Validates that the referenced ``profile`` exists; returns 422 if not.\n- Returns 201 with the created device on success.",
"operationId": "create_device_api_modbus_devices_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceCreate"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/modbus/devices/{uuid}": {
"get": {
"tags": [
"api-modbus"
],
"summary": "Get Device",
"description": "Return a single Modbus device by UUID.",
"operationId": "get_device_api_modbus_devices__uuid__get",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"patch": {
"tags": [
"api-modbus"
],
"summary": "Patch Device",
"description": "Partially update a Modbus device (including enable/disable).\n\nOnly fields explicitly provided in the request body are updated.\nProviding ``profile`` triggers a profile-existence check (422 if unknown).",
"operationId": "patch_device_api_modbus_devices__uuid__patch",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusDeviceResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"api-modbus"
],
"summary": "Delete Device",
"description": "Delete a Modbus device.\n\nReturns 409 Conflict if the device has any associated readings; use\n``enabled=false`` to disable it instead.\n\n**Application-layer safety**: the check is performed via an explicit\nSELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,\nwhich is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).",
"operationId": "delete_device_api_modbus_devices__uuid__delete",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/modbus/devices/{uuid}/latest": {
"get": {
"tags": [
"api-modbus"
],
"summary": "Get Latest Reading",
"description": "Return the most recent reading for a device.\n\nIf no readings exist yet, returns ``{\"found\": false, \"recorded_at\": null,\n\"payload\": null}`` (200, not 404) so the front-end can distinguish\n\"device exists but has no data\" from \"device not found\".",
"operationId": "get_latest_reading_api_modbus_devices__uuid__latest_get",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusLatestResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/modbus/devices/{uuid}/readings": {
"get": {
"tags": [
"api-modbus"
],
"summary": "Get Readings",
"description": "Return time-range readings for a device.\n\nResults are ordered by ``recorded_at`` ascending and capped by ``limit``\n(max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.\n\nThe query uses the ``(device_id, recorded_at)`` composite index for\nefficient time-window scans.\n\nQuery parameters:\n- ``start``: inclusive lower bound (ISO8601 datetime)\n- ``end``: inclusive upper bound (ISO8601 datetime)\n- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})",
"operationId": "get_readings_api_modbus_devices__uuid__readings_get",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
},
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Start"
}
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "End"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 5000,
"minimum": 1,
"default": 500,
"title": "Limit"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusReadingsResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/modbus/devices/{uuid}/metrics": {
"get": {
"tags": [
"api-modbus"
],
"summary": "Get Metrics",
"description": "Return the metric catalogue for a device's profile.\n\nEach entry has ``key``, ``label`` (derived from key if the profile has\nnone — underscores → spaces, title-cased), ``unit``, and ``device_class``.\nThis is the authoritative metadata source for front-end card labels and\nchart axis labels.",
"operationId": "get_metrics_api_modbus_devices__uuid__metrics_get",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusMetricsResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/modbus/devices/{uuid}/test": {
"post": {
"tags": [
"api-modbus"
],
"summary": "Test Read",
"description": "Immediately read and decode the device once without persisting any data.\n\nUseful for validating gateway connectivity and Modbus address settings\nbefore relying on the background polling job.\n\nThis is a write-class endpoint (it initiates network I/O on demand) and\ntherefore requires a CSRF token. The response payload is never stored.",
"operationId": "test_read_api_modbus_devices__uuid__test_post",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Uuid"
}
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModbusTestReadResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/session": {
"get": {
"tags": [
@@ -1367,6 +1863,472 @@
],
"title": "LoginRequest"
},
"MetricInfo": {
"properties": {
"key": {
"type": "string",
"title": "Key"
},
"label": {
"type": "string",
"title": "Label"
},
"unit": {
"type": "string",
"title": "Unit"
},
"device_class": {
"type": "string",
"title": "Device Class"
}
},
"type": "object",
"required": [
"key",
"label",
"unit",
"device_class"
],
"title": "MetricInfo",
"description": "Metadata for a single measurable quantity in a device's profile."
},
"ModbusDeviceCreate": {
"properties": {
"friendly_name": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Friendly Name"
},
"transport": {
"type": "string",
"maxLength": 16,
"title": "Transport",
"default": "tcp"
},
"host": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Host"
},
"port": {
"type": "integer",
"maximum": 65535.0,
"minimum": 1.0,
"title": "Port",
"default": 502
},
"unit_id": {
"type": "integer",
"maximum": 247.0,
"minimum": 0.0,
"title": "Unit Id",
"default": 1
},
"profile": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Profile"
},
"poll_interval_s": {
"type": "integer",
"maximum": 3600.0,
"minimum": 1.0,
"title": "Poll Interval S",
"default": 5
},
"enabled": {
"type": "boolean",
"title": "Enabled",
"default": true
}
},
"type": "object",
"required": [
"friendly_name",
"host",
"profile"
],
"title": "ModbusDeviceCreate",
"description": "Request body for POST /api/modbus/devices."
},
"ModbusDeviceListResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/ModbusDeviceResponse"
},
"type": "array",
"title": "Items"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"items",
"total"
],
"title": "ModbusDeviceListResponse",
"description": "Response schema for listing Modbus devices."
},
"ModbusDeviceResponse": {
"properties": {
"uuid": {
"type": "string",
"title": "Uuid"
},
"friendly_name": {
"type": "string",
"title": "Friendly Name"
},
"transport": {
"type": "string",
"title": "Transport"
},
"host": {
"type": "string",
"title": "Host"
},
"port": {
"type": "integer",
"title": "Port"
},
"unit_id": {
"type": "integer",
"title": "Unit Id"
},
"profile": {
"type": "string",
"title": "Profile"
},
"poll_interval_s": {
"type": "integer",
"title": "Poll Interval S"
},
"enabled": {
"type": "boolean",
"title": "Enabled"
},
"last_poll_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Last Poll At"
},
"last_poll_ok": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Last Poll Ok"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
}
},
"type": "object",
"required": [
"uuid",
"friendly_name",
"transport",
"host",
"port",
"unit_id",
"profile",
"poll_interval_s",
"enabled",
"last_poll_at",
"last_poll_ok",
"created_at",
"updated_at"
],
"title": "ModbusDeviceResponse",
"description": "Response schema for a single Modbus device."
},
"ModbusDeviceUpdate": {
"properties": {
"friendly_name": {
"anyOf": [
{
"type": "string",
"maxLength": 255,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Friendly Name"
},
"transport": {
"anyOf": [
{
"type": "string",
"maxLength": 16
},
{
"type": "null"
}
],
"title": "Transport"
},
"host": {
"anyOf": [
{
"type": "string",
"maxLength": 255,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Host"
},
"port": {
"anyOf": [
{
"type": "integer",
"maximum": 65535.0,
"minimum": 1.0
},
{
"type": "null"
}
],
"title": "Port"
},
"unit_id": {
"anyOf": [
{
"type": "integer",
"maximum": 247.0,
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Unit Id"
},
"profile": {
"anyOf": [
{
"type": "string",
"maxLength": 64,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Profile"
},
"poll_interval_s": {
"anyOf": [
{
"type": "integer",
"maximum": 3600.0,
"minimum": 1.0
},
{
"type": "null"
}
],
"title": "Poll Interval S"
},
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Enabled"
}
},
"type": "object",
"title": "ModbusDeviceUpdate",
"description": "Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."
},
"ModbusLatestResponse": {
"properties": {
"found": {
"type": "boolean",
"title": "Found"
},
"recorded_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Recorded At"
},
"payload": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Payload"
}
},
"type": "object",
"required": [
"found",
"recorded_at",
"payload"
],
"title": "ModbusLatestResponse",
"description": "Response for the /latest endpoint.\n\n``found`` is False and ``recorded_at``/``payload`` are None when the device\nhas no readings yet. Callers should check ``found`` before using the values."
},
"ModbusMetricsResponse": {
"properties": {
"profile": {
"type": "string",
"title": "Profile"
},
"metrics": {
"items": {
"$ref": "#/components/schemas/MetricInfo"
},
"type": "array",
"title": "Metrics"
}
},
"type": "object",
"required": [
"profile",
"metrics"
],
"title": "ModbusMetricsResponse",
"description": "Response schema for GET /api/modbus/devices/{uuid}/metrics."
},
"ModbusProfilesResponse": {
"properties": {
"profiles": {
"items": {
"$ref": "#/components/schemas/ProfileSummary"
},
"type": "array",
"title": "Profiles"
}
},
"type": "object",
"required": [
"profiles"
],
"title": "ModbusProfilesResponse",
"description": "Response schema for GET /api/modbus/profiles."
},
"ModbusReadingResponse": {
"properties": {
"recorded_at": {
"type": "string",
"format": "date-time",
"title": "Recorded At"
},
"payload": {
"additionalProperties": true,
"type": "object",
"title": "Payload"
}
},
"type": "object",
"required": [
"recorded_at",
"payload"
],
"title": "ModbusReadingResponse",
"description": "A single reading row: timestamp + decoded payload."
},
"ModbusReadingsResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/ModbusReadingResponse"
},
"type": "array",
"title": "Items"
}
},
"type": "object",
"required": [
"items"
],
"title": "ModbusReadingsResponse",
"description": "Response schema for the readings time-range endpoint."
},
"ModbusTestReadResponse": {
"properties": {
"ok": {
"type": "boolean",
"title": "Ok"
},
"payload": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Payload"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Error"
}
},
"type": "object",
"required": [
"ok"
],
"title": "ModbusTestReadResponse",
"description": "Response for POST /api/modbus/devices/{uuid}/test.\n\nOn success ``ok=True`` and ``payload`` contains the decoded values.\nOn failure ``ok=False`` and ``error`` describes the problem."
},
"PasswordChangeRequest": {
"properties": {
"current_password": {
@@ -1484,6 +2446,25 @@
"title": "PooUpdateRequest",
"description": "PATCH body for a poo record — all fields optional; PK field excluded."
},
"ProfileSummary": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"description": {
"type": "string",
"title": "Description"
}
},
"type": "object",
"required": [
"name",
"description"
],
"title": "ProfileSummary",
"description": "One entry in the GET /api/modbus/profiles response."
},
"PublicIPCheckResponse": {
"properties": {
"status": {
+711
View File
@@ -452,6 +452,379 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/profiles:
get:
tags:
- api-modbus
summary: Get Profiles
description: 'List all available Modbus YAML profiles (name + description).
Intended for the front-end''s device-creation profile drop-down.'
operationId: get_profiles_api_modbus_profiles_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusProfilesResponse'
/api/modbus/devices:
get:
tags:
- api-modbus
summary: List Devices
description: Return all Modbus devices (no pagination — device counts stay small).
operationId: list_devices_api_modbus_devices_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceListResponse'
post:
tags:
- api-modbus
summary: Create Device
description: 'Create a new Modbus device.
- Validates that the referenced ``profile`` exists; returns 422 if not.
- Returns 201 with the created device on success.'
operationId: create_device_api_modbus_devices_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceCreate'
responses:
'201':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/devices/{uuid}:
get:
tags:
- api-modbus
summary: Get Device
description: Return a single Modbus device by UUID.
operationId: get_device_api_modbus_devices__uuid__get
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
patch:
tags:
- api-modbus
summary: Patch Device
description: 'Partially update a Modbus device (including enable/disable).
Only fields explicitly provided in the request body are updated.
Providing ``profile`` triggers a profile-existence check (422 if unknown).'
operationId: patch_device_api_modbus_devices__uuid__patch
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceUpdate'
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusDeviceResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
delete:
tags:
- api-modbus
summary: Delete Device
description: 'Delete a Modbus device.
Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead.
**Application-layer safety**: the check is performed via an explicit
SELECT COUNT query — not by relying on SQLite''s FK RESTRICT constraint,
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).'
operationId: delete_device_api_modbus_devices__uuid__delete
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'204':
description: Successful Response
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/devices/{uuid}/latest:
get:
tags:
- api-modbus
summary: Get Latest Reading
description: 'Return the most recent reading for a device.
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
"payload": null}`` (200, not 404) so the front-end can distinguish
"device exists but has no data" from "device not found".'
operationId: get_latest_reading_api_modbus_devices__uuid__latest_get
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusLatestResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/devices/{uuid}/readings:
get:
tags:
- api-modbus
summary: Get Readings
description: 'Return time-range readings for a device.
Results are ordered by ``recorded_at`` ascending and capped by ``limit``
(max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
The query uses the ``(device_id, recorded_at)`` composite index for
efficient time-window scans.
Query parameters:
- ``start``: inclusive lower bound (ISO8601 datetime)
- ``end``: inclusive upper bound (ISO8601 datetime)
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})'
operationId: get_readings_api_modbus_devices__uuid__readings_get
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
- name: start
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Start
- name: end
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
title: End
- name: limit
in: query
required: false
schema:
type: integer
maximum: 5000
minimum: 1
default: 500
title: Limit
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusReadingsResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/devices/{uuid}/metrics:
get:
tags:
- api-modbus
summary: Get Metrics
description: 'Return the metric catalogue for a device''s profile.
Each entry has ``key``, ``label`` (derived from key if the profile has
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
This is the authoritative metadata source for front-end card labels and
chart axis labels.'
operationId: get_metrics_api_modbus_devices__uuid__metrics_get
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusMetricsResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/modbus/devices/{uuid}/test:
post:
tags:
- api-modbus
summary: Test Read
description: 'Immediately read and decode the device once without persisting
any data.
Useful for validating gateway connectivity and Modbus address settings
before relying on the background polling job.
This is a write-class endpoint (it initiates network I/O on demand) and
therefore requires a CSRF token. The response payload is never stored.'
operationId: test_read_api_modbus_devices__uuid__test_post
parameters:
- name: uuid
in: path
required: true
schema:
type: string
title: Uuid
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ModbusTestReadResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/session:
get:
tags:
@@ -987,6 +1360,330 @@ components:
- username
- password
title: LoginRequest
MetricInfo:
properties:
key:
type: string
title: Key
label:
type: string
title: Label
unit:
type: string
title: Unit
device_class:
type: string
title: Device Class
type: object
required:
- key
- label
- unit
- device_class
title: MetricInfo
description: Metadata for a single measurable quantity in a device's profile.
ModbusDeviceCreate:
properties:
friendly_name:
type: string
maxLength: 255
minLength: 1
title: Friendly Name
transport:
type: string
maxLength: 16
title: Transport
default: tcp
host:
type: string
maxLength: 255
minLength: 1
title: Host
port:
type: integer
maximum: 65535.0
minimum: 1.0
title: Port
default: 502
unit_id:
type: integer
maximum: 247.0
minimum: 0.0
title: Unit Id
default: 1
profile:
type: string
maxLength: 64
minLength: 1
title: Profile
poll_interval_s:
type: integer
maximum: 3600.0
minimum: 1.0
title: Poll Interval S
default: 5
enabled:
type: boolean
title: Enabled
default: true
type: object
required:
- friendly_name
- host
- profile
title: ModbusDeviceCreate
description: Request body for POST /api/modbus/devices.
ModbusDeviceListResponse:
properties:
items:
items:
$ref: '#/components/schemas/ModbusDeviceResponse'
type: array
title: Items
total:
type: integer
title: Total
type: object
required:
- items
- total
title: ModbusDeviceListResponse
description: Response schema for listing Modbus devices.
ModbusDeviceResponse:
properties:
uuid:
type: string
title: Uuid
friendly_name:
type: string
title: Friendly Name
transport:
type: string
title: Transport
host:
type: string
title: Host
port:
type: integer
title: Port
unit_id:
type: integer
title: Unit Id
profile:
type: string
title: Profile
poll_interval_s:
type: integer
title: Poll Interval S
enabled:
type: boolean
title: Enabled
last_poll_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Last Poll At
last_poll_ok:
anyOf:
- type: boolean
- type: 'null'
title: Last Poll Ok
created_at:
type: string
format: date-time
title: Created At
updated_at:
type: string
format: date-time
title: Updated At
type: object
required:
- uuid
- friendly_name
- transport
- host
- port
- unit_id
- profile
- poll_interval_s
- enabled
- last_poll_at
- last_poll_ok
- created_at
- updated_at
title: ModbusDeviceResponse
description: Response schema for a single Modbus device.
ModbusDeviceUpdate:
properties:
friendly_name:
anyOf:
- type: string
maxLength: 255
minLength: 1
- type: 'null'
title: Friendly Name
transport:
anyOf:
- type: string
maxLength: 16
- type: 'null'
title: Transport
host:
anyOf:
- type: string
maxLength: 255
minLength: 1
- type: 'null'
title: Host
port:
anyOf:
- type: integer
maximum: 65535.0
minimum: 1.0
- type: 'null'
title: Port
unit_id:
anyOf:
- type: integer
maximum: 247.0
minimum: 0.0
- type: 'null'
title: Unit Id
profile:
anyOf:
- type: string
maxLength: 64
minLength: 1
- type: 'null'
title: Profile
poll_interval_s:
anyOf:
- type: integer
maximum: 3600.0
minimum: 1.0
- type: 'null'
title: Poll Interval S
enabled:
anyOf:
- type: boolean
- type: 'null'
title: Enabled
type: object
title: ModbusDeviceUpdate
description: Request body for PATCH /api/modbus/devices/{uuid} — all fields
optional.
ModbusLatestResponse:
properties:
found:
type: boolean
title: Found
recorded_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Recorded At
payload:
anyOf:
- additionalProperties: true
type: object
- type: 'null'
title: Payload
type: object
required:
- found
- recorded_at
- payload
title: ModbusLatestResponse
description: 'Response for the /latest endpoint.
``found`` is False and ``recorded_at``/``payload`` are None when the device
has no readings yet. Callers should check ``found`` before using the values.'
ModbusMetricsResponse:
properties:
profile:
type: string
title: Profile
metrics:
items:
$ref: '#/components/schemas/MetricInfo'
type: array
title: Metrics
type: object
required:
- profile
- metrics
title: ModbusMetricsResponse
description: Response schema for GET /api/modbus/devices/{uuid}/metrics.
ModbusProfilesResponse:
properties:
profiles:
items:
$ref: '#/components/schemas/ProfileSummary'
type: array
title: Profiles
type: object
required:
- profiles
title: ModbusProfilesResponse
description: Response schema for GET /api/modbus/profiles.
ModbusReadingResponse:
properties:
recorded_at:
type: string
format: date-time
title: Recorded At
payload:
additionalProperties: true
type: object
title: Payload
type: object
required:
- recorded_at
- payload
title: ModbusReadingResponse
description: 'A single reading row: timestamp + decoded payload.'
ModbusReadingsResponse:
properties:
items:
items:
$ref: '#/components/schemas/ModbusReadingResponse'
type: array
title: Items
type: object
required:
- items
title: ModbusReadingsResponse
description: Response schema for the readings time-range endpoint.
ModbusTestReadResponse:
properties:
ok:
type: boolean
title: Ok
payload:
anyOf:
- additionalProperties: true
type: object
- type: 'null'
title: Payload
error:
anyOf:
- type: string
- type: 'null'
title: Error
type: object
required:
- ok
title: ModbusTestReadResponse
description: 'Response for POST /api/modbus/devices/{uuid}/test.
On success ``ok=True`` and ``payload`` contains the decoded values.
On failure ``ok=False`` and ``error`` describes the problem.'
PasswordChangeRequest:
properties:
current_password:
@@ -1064,6 +1761,20 @@ components:
type: object
title: PooUpdateRequest
description: PATCH body for a poo record — all fields optional; PK field excluded.
ProfileSummary:
properties:
name:
type: string
title: Name
description:
type: string
title: Description
type: object
required:
- name
- description
title: ProfileSummary
description: One entry in the GET /api/modbus/profiles response.
PublicIPCheckResponse:
properties:
status:
+778
View File
@@ -0,0 +1,778 @@
"""Tests for M5-T05: Modbus JSON API (app/api/routes/api/modbus.py).
Coverage:
- GET /api/modbus/profiles — list profiles
- GET /api/modbus/devices — list devices
- POST /api/modbus/devices — create device (profile validation, auth/CSRF)
- GET /api/modbus/devices/{uuid} — get single device
- PATCH /api/modbus/devices/{uuid} — update device (enable/disable, profile validation)
- DELETE /api/modbus/devices/{uuid} — delete with 409 guard (app-layer, not FK)
- GET /api/modbus/devices/{uuid}/latest — latest reading (found / not-found)
- GET /api/modbus/devices/{uuid}/readings — time-range + limit cap
- GET /api/modbus/devices/{uuid}/metrics — profile metric catalogue
- POST /api/modbus/devices/{uuid}/test — test-read (mocked driver, not persisted)
Auth/CSRF matrix:
- Unauthenticated read → 401
- Authenticated write without CSRF → 403
- Authenticated read → 200/201/204
- Authenticated write + CSRF → 200/201/204
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.modbus import ModbusDevice, ModbusReading
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _create_device_payload(**overrides) -> dict[str, Any]:
base: dict[str, Any] = {
"friendly_name": "Test Meter",
"host": "10.0.0.1",
"port": 502,
"unit_id": 1,
"profile": "sdm120",
"poll_interval_s": 5,
"enabled": True,
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def modbus_client(auth_database):
"""TestClient + engine for Modbus API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine
engine.dispose()
def _make_device(engine, **kwargs) -> ModbusDevice:
"""Insert a ModbusDevice row directly and return it."""
now = datetime.now(UTC)
with Session(engine) as session:
device = ModbusDevice(
friendly_name=kwargs.get("friendly_name", "Test Meter"),
host=kwargs.get("host", "10.0.0.1"),
port=kwargs.get("port", 502),
unit_id=kwargs.get("unit_id", 1),
profile=kwargs.get("profile", "sdm120"),
poll_interval_s=kwargs.get("poll_interval_s", 5),
enabled=kwargs.get("enabled", True),
created_at=now,
updated_at=now,
)
session.add(device)
session.commit()
session.refresh(device)
# Detach from this session so caller can inspect plain attributes.
device_id = device.id
device_uuid = device.uuid
device_friendly_name = device.friendly_name
device_host = device.host
device_profile = device.profile
# Return a simple namespace-like object (uuid is what we mostly need).
class _DeviceStub:
id = device_id
uuid = device_uuid
friendly_name = device_friendly_name
host = device_host
profile = device_profile
return _DeviceStub()
def _make_reading(engine, device_id: int, recorded_at: datetime, payload: dict) -> None:
"""Insert a ModbusReading row directly."""
with Session(engine) as session:
reading = ModbusReading(
device_id=device_id,
recorded_at=recorded_at,
payload=payload,
)
session.add(reading)
session.commit()
# ---------------------------------------------------------------------------
# GET /api/modbus/profiles
# ---------------------------------------------------------------------------
def test_profiles_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.get("/api/modbus/profiles")
assert resp.status_code == 401
def test_profiles_returns_list(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/profiles")
assert resp.status_code == 200
body = resp.json()
assert "profiles" in body
assert isinstance(body["profiles"], list)
# sdm120 profile must be present
names = [p["name"] for p in body["profiles"]]
assert "sdm120" in names
# Each profile has name + description
for p in body["profiles"]:
assert "name" in p
assert "description" in p
# ---------------------------------------------------------------------------
# GET /api/modbus/devices
# ---------------------------------------------------------------------------
def test_list_devices_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.get("/api/modbus/devices")
assert resp.status_code == 401
def test_list_devices_empty(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/devices")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_list_devices_returns_created_device(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get("/api/modbus/devices")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["uuid"] == device.uuid
# ---------------------------------------------------------------------------
# POST /api/modbus/devices
# ---------------------------------------------------------------------------
def test_create_device_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_create_device_missing_csrf_returns_403(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post("/api/modbus/devices", json=_create_device_payload())
assert resp.status_code == 403
def test_create_device_success(modbus_client):
client, engine = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
assert body["friendly_name"] == "Test Meter"
assert "uuid" in body
assert len(body["uuid"]) == 36 # uuid4 format
# Verify row exists in DB
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 1
def test_create_device_invalid_profile_returns_422(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(profile="nonexistent_profile_xyz"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
assert "nonexistent_profile_xyz" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_get_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 401
def test_get_device_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/devices/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
def test_get_device_returns_correct_fields(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, friendly_name="My Meter")
resp = client.get(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 200
body = resp.json()
assert body["uuid"] == device.uuid
assert body["friendly_name"] == "My Meter"
assert body["profile"] == "sdm120"
assert "last_poll_at" in body
assert "last_poll_ok" in body
assert "created_at" in body
assert "updated_at" in body
# ---------------------------------------------------------------------------
# PATCH /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_patch_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_patch_device_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
)
assert resp.status_code == 403
def test_patch_device_enable_disable(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, enabled=True)
# Disable
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is False
# Re-enable
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": True},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is True
def test_patch_device_invalid_profile_returns_422(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"profile": "nonexistent_xyz"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_patch_device_updates_correct_fields(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"friendly_name": "Renamed Meter", "port": 503},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["friendly_name"] == "Renamed Meter"
assert body["port"] == 503
# Other fields unchanged
assert body["uuid"] == device.uuid
# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_delete_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_delete_device_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 403
def test_delete_device_no_readings_succeeds(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 204
# Confirm device is gone
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 0
def test_delete_device_with_readings_returns_409(modbus_client):
"""Delete with existing readings must return 409 (application-layer check, not FK)."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
# Add a reading
_make_reading(
engine,
device_id=device.id,
recorded_at=datetime.now(UTC),
payload={"voltage": 230.0},
)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 409
assert "reading" in resp.json()["detail"].lower()
# Device still exists
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 1
def test_delete_device_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.delete(
"/api/modbus/devices/00000000-0000-0000-0000-000000000000",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/latest
# ---------------------------------------------------------------------------
def test_latest_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 401
def test_latest_no_readings_returns_found_false(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is False
assert body["recorded_at"] is None
assert body["payload"] is None
def test_latest_returns_most_recent_reading(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
earlier = now - timedelta(minutes=10)
later = now - timedelta(minutes=1)
_make_reading(engine, device.id, earlier, {"voltage": 228.0})
_make_reading(engine, device.id, later, {"voltage": 232.0})
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is True
assert body["payload"] == {"voltage": 232.0}
assert body["recorded_at"] is not None
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/readings
# ---------------------------------------------------------------------------
def test_readings_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 401
def test_readings_empty_returns_empty_list(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
assert resp.json()["items"] == []
def test_readings_returns_all_by_default_ordered_ascending(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
t1 = now - timedelta(minutes=30)
t2 = now - timedelta(minutes=20)
t3 = now - timedelta(minutes=10)
_make_reading(engine, device.id, t3, {"voltage": 233.0})
_make_reading(engine, device.id, t1, {"voltage": 231.0})
_make_reading(engine, device.id, t2, {"voltage": 232.0})
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 3
# Ascending order
voltages = [item["payload"]["voltage"] for item in items]
assert voltages == [231.0, 232.0, 233.0]
def test_readings_time_window_filters_correctly(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
t_old = now - timedelta(hours=2)
t_mid = now - timedelta(hours=1)
t_new = now - timedelta(minutes=5)
_make_reading(engine, device.id, t_old, {"voltage": 228.0})
_make_reading(engine, device.id, t_mid, {"voltage": 230.0})
_make_reading(engine, device.id, t_new, {"voltage": 232.0})
# Only the last 90 minutes
start = (now - timedelta(minutes=90)).isoformat()
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"start": start},
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 2
voltages = {item["payload"]["voltage"] for item in items}
assert 228.0 not in voltages
def test_readings_limit_applied(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
for i in range(10):
_make_reading(engine, device.id, now - timedelta(minutes=i), {"voltage": float(i)})
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"limit": 3},
)
assert resp.status_code == 200
assert len(resp.json()["items"]) == 3
def test_readings_limit_exceeds_max_returns_422(modbus_client):
"""A limit > 5000 must be rejected by FastAPI query validation (422)."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"limit": 9999},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/metrics
# ---------------------------------------------------------------------------
def test_metrics_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
assert resp.status_code == 401
def test_metrics_returns_profile_catalogue(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, profile="sdm120")
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
assert resp.status_code == 200
body = resp.json()
assert body["profile"] == "sdm120"
assert isinstance(body["metrics"], list)
assert len(body["metrics"]) > 0
# Each metric has required fields
for m in body["metrics"]:
assert "key" in m
assert "label" in m
assert "unit" in m
assert "device_class" in m
# SDM120 must include voltage
keys = [m["key"] for m in body["metrics"]]
assert "voltage" in keys
# Label is human-readable (not raw snake_case for "active_power")
active_power = next((m for m in body["metrics"] if m["key"] == "active_power"), None)
assert active_power is not None
assert "Active Power" == active_power["label"]
def test_metrics_label_derived_from_key(modbus_client):
"""Verify the label derivation helper converts snake_case to Title Case."""
from app.api.routes.api.modbus import _label_from_key
assert _label_from_key("voltage") == "Voltage"
assert _label_from_key("active_power") == "Active Power"
assert _label_from_key("import_energy") == "Import Energy"
assert _label_from_key("power_factor") == "Power Factor"
# ---------------------------------------------------------------------------
# POST /api/modbus/devices/{uuid}/test
# ---------------------------------------------------------------------------
def test_test_read_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_test_read_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.post(f"/api/modbus/devices/{device.uuid}/test")
assert resp.status_code == 403
def test_test_read_success_returns_payload_and_does_not_persist(modbus_client):
"""Successful test-read returns payload and stores nothing in modbus_reading."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
known_payload = {"voltage": 230.2, "current": 1.3}
with (
patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read,
patch("app.api.routes.api.modbus.decode_profile") as mock_decode,
):
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = known_payload
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["payload"] == known_payload
assert body["error"] is None
# Verify nothing was persisted
with Session(engine) as session:
readings = session.query(ModbusReading).all()
assert len(readings) == 0
def test_test_read_driver_failure_returns_ok_false(modbus_client):
"""When the driver raises, test-read returns ok=False with an error message."""
from app.integrations.modbus.driver import ModbusConnectionError
client, engine = modbus_client
_login(client)
device = _make_device(engine)
with patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read:
mock_read.side_effect = ModbusConnectionError("Cannot reach gateway")
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is False
assert body["error"] is not None
assert "Cannot reach gateway" in body["error"]
# Still no readings in DB
with Session(engine) as session:
readings = session.query(ModbusReading).all()
assert len(readings) == 0
def test_test_read_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices/00000000-0000-0000-0000-000000000000/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Row count integrity: CRUD operations produce the right row count
# ---------------------------------------------------------------------------
def test_crud_row_counts(modbus_client):
"""Create 2 devices, verify row count; delete one (no readings), verify count drops."""
client, engine = modbus_client
_login(client)
resp1 = client.post(
"/api/modbus/devices",
json=_create_device_payload(friendly_name="Meter A"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
uuid_a = resp1.json()["uuid"]
resp2 = client.post(
"/api/modbus/devices",
json=_create_device_payload(friendly_name="Meter B"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
# 2 devices exist
with Session(engine) as session:
count = session.query(ModbusDevice).count()
assert count == 2
# Delete Meter A (no readings)
resp_del = client.delete(
f"/api/modbus/devices/{uuid_a}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp_del.status_code == 204
# 1 device remains
with Session(engine) as session:
count = session.query(ModbusDevice).count()
assert count == 1
def test_readings_each_item_has_recorded_at_and_payload(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
_make_reading(engine, device.id, now, {"voltage": 230.0, "current": 1.5})
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
item = items[0]
assert "recorded_at" in item
assert "payload" in item
assert item["payload"]["voltage"] == 230.0