From 702a1cec00406450b1728675e66bfe85c803cf62 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 22 Jun 2026 13:37:48 +0200 Subject: [PATCH] M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test) --- app/api/routes/api/modbus.py | 433 ++++++++++++++++ app/main.py | 2 + app/schemas/modbus.py | 154 ++++++ docs/design/m5-iot-energy.md | 2 +- openapi/openapi.json | 981 +++++++++++++++++++++++++++++++++++ openapi/openapi.yaml | 711 +++++++++++++++++++++++++ tests/test_api_modbus.py | 778 +++++++++++++++++++++++++++ 7 files changed, 3060 insertions(+), 1 deletion(-) create mode 100644 app/api/routes/api/modbus.py create mode 100644 app/schemas/modbus.py create mode 100644 tests/test_api_modbus.py diff --git a/app/api/routes/api/modbus.py b/app/api/routes/api/modbus.py new file mode 100644 index 0000000..5cbbd41 --- /dev/null +++ b/app/api/routes/api/modbus.py @@ -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}") diff --git a/app/main.py b/app/main.py index 2a4b91f..f6688a7 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/schemas/modbus.py b/app/schemas/modbus.py new file mode 100644 index 0000000..9918ce3 --- /dev/null +++ b/app/schemas/modbus.py @@ -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 diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 0ddc258..2d04e55 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -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**: diff --git a/openapi/openapi.json b/openapi/openapi.json index 7956d51..85f5d4a 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -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": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index dadbd96..69f53b9 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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: diff --git a/tests/test_api_modbus.py b/tests/test_api_modbus.py new file mode 100644 index 0000000..8e481f8 --- /dev/null +++ b/tests/test_api_modbus.py @@ -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