M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test)
This commit is contained in:
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -345,7 +345,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
||||
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。
|
||||
|
||||
### M5-T05 — Modbus JSON API(device 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**:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user