diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md
index 2d04e55..f8765b2 100644
--- a/docs/design/m5-iot-energy.md
+++ b/docs/design/m5-iot-energy.md
@@ -363,7 +363,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。
### M5-T06 — 前端:设备管理 UI(Energy 视图)
-- **Status**: `todo` · **Depends**: M5-T01, M5-T05
+- **Status**: `done` · **Depends**: M5-T01, M5-T05
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/DeviceForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c2aa217..5b54d8d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,7 @@
* / → ProtectedRoute → AppLayout → HomePage
* /config → ProtectedRoute → AppLayout → ConfigPage
* /records → ProtectedRoute → AppLayout → RecordsPage
+ * /energy → ProtectedRoute → AppLayout → EnergyPage
*
* AppLayout renders a sidebar (AppSidebar) on the left; page content via on the right.
* /login and /change-password have no sidebar layout.
@@ -29,6 +30,7 @@ import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage'
+import { EnergyPage } from './pages/EnergyPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
import { AppSidebar } from './components/AppSidebar'
@@ -125,6 +127,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index a78f6e4..f24a518 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -210,6 +210,199 @@ export interface paths {
patch: operations["patch_poo_api_poo__timestamp__patch"];
trace?: never;
};
+ "/api/modbus/profiles": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Profiles
+ * @description List all available Modbus YAML profiles (name + description).
+ *
+ * Intended for the front-end's device-creation profile drop-down.
+ */
+ get: operations["get_profiles_api_modbus_profiles_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/modbus/devices": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Devices
+ * @description Return all Modbus devices (no pagination — device counts stay small).
+ */
+ get: operations["list_devices_api_modbus_devices_get"];
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["create_device_api_modbus_devices_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/modbus/devices/{uuid}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Device
+ * @description Return a single Modbus device by UUID.
+ */
+ get: operations["get_device_api_modbus_devices__uuid__get"];
+ put?: never;
+ post?: never;
+ /**
+ * 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).
+ */
+ delete: operations["delete_device_api_modbus_devices__uuid__delete"];
+ options?: never;
+ head?: never;
+ /**
+ * 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).
+ */
+ patch: operations["patch_device_api_modbus_devices__uuid__patch"];
+ trace?: never;
+ };
+ "/api/modbus/devices/{uuid}/latest": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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".
+ */
+ get: operations["get_latest_reading_api_modbus_devices__uuid__latest_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/modbus/devices/{uuid}/readings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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})
+ */
+ get: operations["get_readings_api_modbus_devices__uuid__readings_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/modbus/devices/{uuid}/metrics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["get_metrics_api_modbus_devices__uuid__metrics_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/modbus/devices/{uuid}/test": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["test_read_api_modbus_devices__uuid__test_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/session": {
parameters: {
query?: never;
@@ -620,6 +813,202 @@ export interface components {
/** Totp Code */
totp_code?: string | null;
};
+ /**
+ * MetricInfo
+ * @description Metadata for a single measurable quantity in a device's profile.
+ */
+ MetricInfo: {
+ /** Key */
+ key: string;
+ /** Label */
+ label: string;
+ /** Unit */
+ unit: string;
+ /** Device Class */
+ device_class: string;
+ };
+ /**
+ * ModbusDeviceCreate
+ * @description Request body for POST /api/modbus/devices.
+ */
+ ModbusDeviceCreate: {
+ /** Friendly Name */
+ friendly_name: string;
+ /**
+ * Transport
+ * @default tcp
+ */
+ transport: string;
+ /** Host */
+ host: string;
+ /**
+ * Port
+ * @default 502
+ */
+ port: number;
+ /**
+ * Unit Id
+ * @default 1
+ */
+ unit_id: number;
+ /** Profile */
+ profile: string;
+ /**
+ * Poll Interval S
+ * @default 5
+ */
+ poll_interval_s: number;
+ /**
+ * Enabled
+ * @default true
+ */
+ enabled: boolean;
+ };
+ /**
+ * ModbusDeviceListResponse
+ * @description Response schema for listing Modbus devices.
+ */
+ ModbusDeviceListResponse: {
+ /** Items */
+ items: components["schemas"]["ModbusDeviceResponse"][];
+ /** Total */
+ total: number;
+ };
+ /**
+ * ModbusDeviceResponse
+ * @description Response schema for a single Modbus device.
+ */
+ ModbusDeviceResponse: {
+ /** Uuid */
+ uuid: string;
+ /** Friendly Name */
+ friendly_name: string;
+ /** Transport */
+ transport: string;
+ /** Host */
+ host: string;
+ /** Port */
+ port: number;
+ /** Unit Id */
+ unit_id: number;
+ /** Profile */
+ profile: string;
+ /** Poll Interval S */
+ poll_interval_s: number;
+ /** Enabled */
+ enabled: boolean;
+ /** Last Poll At */
+ last_poll_at: string | null;
+ /** Last Poll Ok */
+ last_poll_ok: boolean | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * Format: date-time
+ */
+ updated_at: string;
+ };
+ /**
+ * ModbusDeviceUpdate
+ * @description Request body for PATCH /api/modbus/devices/{uuid} — all fields optional.
+ */
+ ModbusDeviceUpdate: {
+ /** Friendly Name */
+ friendly_name?: string | null;
+ /** Transport */
+ transport?: string | null;
+ /** Host */
+ host?: string | null;
+ /** Port */
+ port?: number | null;
+ /** Unit Id */
+ unit_id?: number | null;
+ /** Profile */
+ profile?: string | null;
+ /** Poll Interval S */
+ poll_interval_s?: number | null;
+ /** Enabled */
+ enabled?: boolean | null;
+ };
+ /**
+ * 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.
+ */
+ ModbusLatestResponse: {
+ /** Found */
+ found: boolean;
+ /** Recorded At */
+ recorded_at: string | null;
+ /** Payload */
+ payload: {
+ [key: string]: unknown;
+ } | null;
+ };
+ /**
+ * ModbusMetricsResponse
+ * @description Response schema for GET /api/modbus/devices/{uuid}/metrics.
+ */
+ ModbusMetricsResponse: {
+ /** Profile */
+ profile: string;
+ /** Metrics */
+ metrics: components["schemas"]["MetricInfo"][];
+ };
+ /**
+ * ModbusProfilesResponse
+ * @description Response schema for GET /api/modbus/profiles.
+ */
+ ModbusProfilesResponse: {
+ /** Profiles */
+ profiles: components["schemas"]["ProfileSummary"][];
+ };
+ /**
+ * ModbusReadingResponse
+ * @description A single reading row: timestamp + decoded payload.
+ */
+ ModbusReadingResponse: {
+ /**
+ * Recorded At
+ * Format: date-time
+ */
+ recorded_at: string;
+ /** Payload */
+ payload: {
+ [key: string]: unknown;
+ };
+ };
+ /**
+ * ModbusReadingsResponse
+ * @description Response schema for the readings time-range endpoint.
+ */
+ ModbusReadingsResponse: {
+ /** Items */
+ items: components["schemas"]["ModbusReadingResponse"][];
+ };
+ /**
+ * 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.
+ */
+ ModbusTestReadResponse: {
+ /** Ok */
+ ok: boolean;
+ /** Payload */
+ payload?: {
+ [key: string]: unknown;
+ } | null;
+ /** Error */
+ error?: string | null;
+ };
/** PasswordChangeRequest */
PasswordChangeRequest: {
/** Current Password */
@@ -661,6 +1050,16 @@ export interface components {
/** Longitude */
longitude?: number | null;
};
+ /**
+ * ProfileSummary
+ * @description One entry in the GET /api/modbus/profiles response.
+ */
+ ProfileSummary: {
+ /** Name */
+ name: string;
+ /** Description */
+ description: string;
+ };
/** PublicIPCheckResponse */
PublicIPCheckResponse: {
/**
@@ -1177,6 +1576,310 @@ export interface operations {
};
};
};
+ get_profiles_api_modbus_profiles_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusProfilesResponse"];
+ };
+ };
+ };
+ };
+ list_devices_api_modbus_devices_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceListResponse"];
+ };
+ };
+ };
+ };
+ create_device_api_modbus_devices_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_device_api_modbus_devices__uuid__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_device_api_modbus_devices__uuid__delete: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ patch_device_api_modbus_devices__uuid__patch: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceUpdate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_latest_reading_api_modbus_devices__uuid__latest_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusLatestResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_readings_api_modbus_devices__uuid__readings_get: {
+ parameters: {
+ query?: {
+ start?: string | null;
+ end?: string | null;
+ limit?: number;
+ };
+ header?: never;
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusReadingsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_metrics_api_modbus_devices__uuid__metrics_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusMetricsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ test_read_api_modbus_devices__uuid__test_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModbusTestReadResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_session_api_session_get: {
parameters: {
query?: never;
diff --git a/frontend/src/components/AppSidebar.test.tsx b/frontend/src/components/AppSidebar.test.tsx
index 88aef44..ed32808 100644
--- a/frontend/src/components/AppSidebar.test.tsx
+++ b/frontend/src/components/AppSidebar.test.tsx
@@ -1,15 +1,16 @@
/**
- * Tests for AppSidebar (M5-T01).
+ * Tests for AppSidebar (M5-T01, updated M5-T06).
*
* Strategy: render AppSidebar inside a minimal AppShell (required by
* AppShell.Navbar) + MemoryRouter so useLocation() works.
*
* Coverage:
- * 1. All three nav items render (Home, Records, Config).
+ * 1. All four nav items render (Home, Records, Energy, Config).
* 2. Home nav item is active when pathname is '/'.
* 3. Records nav item is active when pathname is '/records'.
* 4. Config nav item is active when pathname is '/config'.
* 5. Home is NOT active when on '/records'.
+ * 6. Energy nav item is active when on '/energy'.
*/
import { describe, it, expect, vi } from 'vitest'
@@ -76,10 +77,11 @@ describe('AppSidebar', () => {
// 1. All nav items render
// -------------------------------------------------------------------------
- it('renders Home, Records, and Config nav items', () => {
+ it('renders Home, Records, Energy, and Config nav items', () => {
renderSidebar('/')
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
+ expect(screen.getByTestId('nav-energy')).toBeInTheDocument()
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
})
@@ -123,4 +125,14 @@ describe('AppSidebar', () => {
const homeLink = screen.getByTestId('nav-home')
expect(homeLink).not.toHaveAttribute('data-active', 'true')
})
+
+ // -------------------------------------------------------------------------
+ // 6. Energy active on '/energy'
+ // -------------------------------------------------------------------------
+
+ it('marks Energy nav item as active when on "/energy"', () => {
+ renderSidebar('/energy')
+ const energyLink = screen.getByTestId('nav-energy')
+ expect(energyLink).toHaveAttribute('data-active', 'true')
+ })
})
diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx
index c2e0f94..e4465d2 100644
--- a/frontend/src/components/AppSidebar.tsx
+++ b/frontend/src/components/AppSidebar.tsx
@@ -1,18 +1,16 @@
/**
* AppSidebar — vertical sidebar navigation for all protected pages.
*
- * Nav items: Home / Records / Config
+ * Nav items: Home / Records / Energy / Config
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
*
* Current route is highlighted via useLocation().
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
- *
- * NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
*/
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
import { Link, useLocation, useNavigate } from 'react-router-dom'
-import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather'
+import { Home, List, Settings, Sun, Moon, LogOut, Zap } from 'react-feather'
import { useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
@@ -40,6 +38,12 @@ const NAV_ENTRIES: NavEntry[] = [
icon:
,
testId: 'nav-records',
},
+ {
+ to: '/energy',
+ label: 'Energy',
+ icon: ,
+ testId: 'nav-energy',
+ },
{
to: '/config',
label: 'Config',
diff --git a/frontend/src/energy/DeviceForm.test.tsx b/frontend/src/energy/DeviceForm.test.tsx
new file mode 100644
index 0000000..3949a8a
--- /dev/null
+++ b/frontend/src/energy/DeviceForm.test.tsx
@@ -0,0 +1,241 @@
+/**
+ * Tests for energy/DeviceForm.tsx
+ *
+ * Coverage:
+ * 1. Renders empty form in create mode (title "New Device").
+ * 2. Renders pre-filled form in edit mode (title "Edit Device"), shows UUID.
+ * 3. Validation: missing friendly_name shows error.
+ * 4. Profile dropdown loads options from GET /api/modbus/profiles.
+ * 5. Submit in create mode calls POST /api/modbus/devices.
+ * 6. Submit in edit mode calls PATCH /api/modbus/devices/{uuid}.
+ * 7. Cancel button invokes onClose without submitting.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { renderWithProviders } from '../test-utils'
+import { DeviceForm } from './DeviceForm'
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const DEVICE = {
+ uuid: 'abc-123',
+ friendly_name: 'SDM120 Main',
+ transport: 'tcp',
+ host: '192.168.1.100',
+ port: 502,
+ unit_id: 1,
+ profile: 'sdm120',
+ poll_interval_s: 5,
+ enabled: true,
+ last_poll_at: null,
+ last_poll_ok: null,
+ created_at: '2026-06-01T00:00:00Z',
+ updated_at: '2026-06-01T00:00:00Z',
+}
+
+const PROFILES_RESP = {
+ profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
+}
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockPatch = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ GET: (...args: unknown[]) => mockGet(...args),
+ POST: (...args: unknown[]) => mockPost(...args),
+ PATCH: (...args: unknown[]) => mockPatch(...args),
+ DELETE: vi.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function setupProfilesMock() {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/api/modbus/profiles') {
+ return Promise.resolve({ data: PROFILES_RESP })
+ }
+ // modbus-devices for invalidation
+ return Promise.resolve({ data: { items: [], total: 0 } })
+ })
+}
+
+function renderCreateForm(onClose = vi.fn(), onSaved = vi.fn()) {
+ return renderWithProviders(, {
+ initialPath: '/',
+ })
+}
+
+function renderEditForm(onClose = vi.fn(), onSaved = vi.fn()) {
+ return renderWithProviders(, {
+ initialPath: '/',
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('DeviceForm — create mode', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupProfilesMock()
+ })
+
+ it('renders with title "New Device" and empty friendly-name input', async () => {
+ renderCreateForm()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
+ })
+
+ // Modal title
+ expect(screen.getByText('New Device')).toBeInTheDocument()
+
+ // UUID display should NOT be present in create mode
+ expect(screen.queryByTestId('device-uuid-display')).not.toBeInTheDocument()
+ })
+
+ it('loads profile options from GET /api/modbus/profiles', async () => {
+ renderCreateForm()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form')).toBeInTheDocument()
+ })
+
+ // Profile select should be rendered
+ expect(screen.getByTestId('device-profile')).toBeInTheDocument()
+ expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
+ })
+
+ it('shows validation error when friendly name is empty', async () => {
+ renderCreateForm()
+
+ await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
+
+ fireEvent.submit(screen.getByTestId('device-form'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('device-form-error').textContent).toContain('Friendly name')
+ })
+
+ it('submit calls POST /api/modbus/devices with correct body (profile auto-selected)', async () => {
+ mockPost.mockResolvedValue({ data: DEVICE })
+ const onSaved = vi.fn()
+ renderCreateForm(vi.fn(), onSaved)
+
+ // Wait for profiles to load; sdm120 is auto-selected as first profile.
+ await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
+
+ // Fill form (profile auto-selected to 'sdm120')
+ fireEvent.change(screen.getByTestId('device-friendly-name'), {
+ target: { value: 'My Meter' },
+ })
+ fireEvent.change(screen.getByTestId('device-host'), {
+ target: { value: '10.0.0.1' },
+ })
+
+ fireEvent.submit(screen.getByTestId('device-form'))
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith(
+ '/api/modbus/devices',
+ expect.objectContaining({
+ body: expect.objectContaining({
+ friendly_name: 'My Meter',
+ host: '10.0.0.1',
+ transport: 'tcp',
+ profile: 'sdm120',
+ }),
+ }),
+ )
+ })
+ })
+
+ it('cancel calls onClose without POST', async () => {
+ const onClose = vi.fn()
+ renderCreateForm(onClose)
+
+ await waitFor(() => expect(screen.getByTestId('device-form-cancel')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByTestId('device-form-cancel'))
+
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(mockPost).not.toHaveBeenCalled()
+ })
+})
+
+describe('DeviceForm — edit mode', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupProfilesMock()
+ })
+
+ it('renders with title "Edit Device" and pre-filled friendly-name', async () => {
+ renderEditForm()
+
+ // Wait for modal to appear
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Edit Device')).toBeInTheDocument()
+
+ // Wait for form to appear (profiles must load first)
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('device-uuid-display').textContent).toContain('abc-123')
+
+ // Friendly name input should be pre-filled
+ const input = screen.getByTestId('device-friendly-name') as HTMLInputElement
+ expect(input.value).toBe('SDM120 Main')
+ })
+
+ it('submit calls PATCH /api/modbus/devices/{uuid}', async () => {
+ mockPatch.mockResolvedValue({ data: DEVICE })
+ renderEditForm()
+
+ // Wait for profiles to load so the form is visible
+ await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
+
+ fireEvent.change(screen.getByTestId('device-friendly-name'), {
+ target: { value: 'SDM120 Updated' },
+ })
+
+ fireEvent.submit(screen.getByTestId('device-form'))
+
+ await waitFor(() => {
+ expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid: 'abc-123' } },
+ body: expect.objectContaining({ friendly_name: 'SDM120 Updated' }),
+ })
+ })
+ })
+})
diff --git a/frontend/src/energy/DeviceForm.tsx b/frontend/src/energy/DeviceForm.tsx
new file mode 100644
index 0000000..a50e71b
--- /dev/null
+++ b/frontend/src/energy/DeviceForm.tsx
@@ -0,0 +1,263 @@
+/**
+ * DeviceForm — create / edit a Modbus device.
+ *
+ * Used for both new-device creation (no `device` prop) and editing an
+ * existing device (pass `device` prop with current values).
+ *
+ * Profile list is fetched from GET /api/modbus/profiles and rendered as a
+ * Select dropdown. All other fields are inline inputs.
+ */
+
+import { useState } from 'react'
+import {
+ Modal,
+ Stack,
+ TextInput,
+ NumberInput,
+ Select,
+ Switch,
+ Button,
+ Group,
+ Alert,
+ Loader,
+ Center,
+ Text,
+} from '@mantine/core'
+import { useProfiles, useCreateDevice, useUpdateDevice } from './hooks'
+import type { ModbusDevice, ModbusDeviceCreate, ModbusDeviceUpdate } from './hooks'
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+interface DeviceFormProps {
+ /** When provided, the form is in edit mode; otherwise create mode. */
+ device?: ModbusDevice
+ onClose: () => void
+ onSaved: () => void
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function DeviceForm({ device, onClose, onSaved }: DeviceFormProps) {
+ const isEdit = !!device
+
+ // Form state — initialise from device if editing.
+ const [friendlyName, setFriendlyName] = useState(device?.friendly_name ?? '')
+ const [host, setHost] = useState(device?.host ?? '')
+ const [port, setPort] = useState(device?.port ?? 502)
+ const [unitId, setUnitId] = useState(device?.unit_id ?? 1)
+ const [profile, setProfile] = useState(device?.profile ?? null)
+ const [pollIntervalS, setPollIntervalS] = useState(device?.poll_interval_s ?? 5)
+ const [enabled, setEnabled] = useState(device?.enabled ?? true)
+ const [error, setError] = useState(null)
+
+ const profilesQuery = useProfiles()
+ const createMutation = useCreateDevice()
+ const updateMutation = useUpdateDevice()
+
+ const isBusy = createMutation.isPending || updateMutation.isPending
+
+ // Build select options from profiles response.
+ const profileOptions =
+ profilesQuery.data?.profiles.map((p) => ({
+ value: p.name,
+ label: `${p.name} — ${p.description}`,
+ })) ?? []
+
+ // In create mode, if no explicit selection has been made and profiles are loaded,
+ // derive a default from the first available profile. We avoid useEffect+setState
+ // (causes cascading renders) by computing the effective value here instead.
+ const effectiveProfile: string | null =
+ profile !== null
+ ? profile
+ : !isEdit && profileOptions.length > 0
+ ? profileOptions[0].value
+ : null
+
+ // ---------------------------------------------------------------------------
+ // Validation
+ // ---------------------------------------------------------------------------
+
+ function validate(): string | null {
+ if (!friendlyName.trim()) return 'Friendly name is required.'
+ if (!host.trim()) return 'Host is required.'
+ const portNum = Number(port)
+ if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
+ return 'Port must be an integer between 1 and 65535.'
+ const unitNum = Number(unitId)
+ if (!Number.isInteger(unitNum) || unitNum < 1 || unitNum > 247)
+ return 'Unit ID must be an integer between 1 and 247.'
+ if (!effectiveProfile) return 'Profile is required.'
+ const pollNum = Number(pollIntervalS)
+ if (!Number.isInteger(pollNum) || pollNum < 1)
+ return 'Poll interval must be a positive integer (seconds).'
+ return null
+ }
+
+ // ---------------------------------------------------------------------------
+ // Submit
+ // ---------------------------------------------------------------------------
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+
+ const validationError = validate()
+ if (validationError) {
+ setError(validationError)
+ return
+ }
+
+ try {
+ if (isEdit && device) {
+ const body: ModbusDeviceUpdate = {
+ friendly_name: friendlyName.trim(),
+ host: host.trim(),
+ port: Number(port),
+ unit_id: Number(unitId),
+ profile: effectiveProfile!,
+ poll_interval_s: Number(pollIntervalS),
+ enabled,
+ }
+ await updateMutation.mutateAsync({ uuid: device.uuid, body })
+ } else {
+ const body: ModbusDeviceCreate = {
+ friendly_name: friendlyName.trim(),
+ transport: 'tcp',
+ host: host.trim(),
+ port: Number(port),
+ unit_id: Number(unitId),
+ profile: effectiveProfile!,
+ poll_interval_s: Number(pollIntervalS),
+ enabled,
+ }
+ await createMutation.mutateAsync(body)
+ }
+ onSaved()
+ onClose()
+ } catch {
+ setError('Failed to save device. Please try again.')
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render
+ // ---------------------------------------------------------------------------
+
+ return (
+
+ {profilesQuery.isLoading && (
+
+
+
+ )}
+
+ {profilesQuery.isError && (
+
+ Failed to load profiles. Cannot create device.
+
+ )}
+
+ {!profilesQuery.isLoading && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/hooks.test.tsx b/frontend/src/energy/hooks.test.tsx
new file mode 100644
index 0000000..1a6a74d
--- /dev/null
+++ b/frontend/src/energy/hooks.test.tsx
@@ -0,0 +1,214 @@
+/**
+ * Tests for energy/hooks.ts — Modbus API wrappers.
+ *
+ * Coverage:
+ * 1. useDevices — calls GET /api/modbus/devices and returns data.
+ * 2. useProfiles — calls GET /api/modbus/profiles.
+ * 3. useCreateDevice — calls POST /api/modbus/devices with body.
+ * 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body.
+ * 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}.
+ * 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, waitFor, act } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { ReactNode } from 'react'
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockPatch = vi.fn()
+const mockDelete = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ GET: (...args: unknown[]) => mockGet(...args),
+ POST: (...args: unknown[]) => mockPost(...args),
+ PATCH: (...args: unknown[]) => mockPatch(...args),
+ DELETE: (...args: unknown[]) => mockDelete(...args),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const DEVICE = {
+ uuid: 'test-uuid-1',
+ friendly_name: 'SDM120 Main',
+ transport: 'tcp',
+ host: '192.168.1.100',
+ port: 502,
+ unit_id: 1,
+ profile: 'sdm120',
+ poll_interval_s: 5,
+ enabled: true,
+ last_poll_at: null,
+ last_poll_ok: null,
+ created_at: '2026-06-01T00:00:00Z',
+ updated_at: '2026-06-01T00:00:00Z',
+}
+
+// ---------------------------------------------------------------------------
+// Wrapper
+// ---------------------------------------------------------------------------
+
+function makeWrapper() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+ return { qc, Wrapper }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useDevices', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls GET /api/modbus/devices and returns device list', async () => {
+ mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } })
+
+ const { Wrapper } = makeWrapper()
+ const { useDevices } = await import('./hooks')
+ const { result } = renderHook(() => useDevices(), { wrapper: Wrapper })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices')
+ expect(result.current.data?.items).toHaveLength(1)
+ expect(result.current.data?.items[0].uuid).toBe('test-uuid-1')
+ })
+})
+
+describe('useProfiles', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls GET /api/modbus/profiles and returns profiles', async () => {
+ mockGet.mockResolvedValue({
+ data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] },
+ })
+
+ const { Wrapper } = makeWrapper()
+ const { useProfiles } = await import('./hooks')
+ const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
+ expect(result.current.data?.profiles[0].name).toBe('sdm120')
+ })
+})
+
+describe('useCreateDevice', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls POST /api/modbus/devices with the provided body', async () => {
+ mockPost.mockResolvedValue({ data: DEVICE })
+ // LIST query to satisfy invalidation
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ const { Wrapper } = makeWrapper()
+ const { useCreateDevice } = await import('./hooks')
+ const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper })
+
+ const body = {
+ friendly_name: 'SDM120 Main',
+ transport: 'tcp',
+ host: '192.168.1.100',
+ port: 502,
+ unit_id: 1,
+ profile: 'sdm120',
+ poll_interval_s: 5,
+ enabled: true,
+ }
+
+ await act(async () => {
+ await result.current.mutateAsync(body)
+ })
+
+ expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body })
+ })
+})
+
+describe('useUpdateDevice', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => {
+ mockPatch.mockResolvedValue({ data: DEVICE })
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ const { Wrapper } = makeWrapper()
+ const { useUpdateDevice } = await import('./hooks')
+ const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper })
+
+ await act(async () => {
+ await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } })
+ })
+
+ expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid: 'test-uuid-1' } },
+ body: { enabled: false },
+ })
+ })
+})
+
+describe('useDeleteDevice', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls DELETE /api/modbus/devices/{uuid}', async () => {
+ mockDelete.mockResolvedValue({ data: null })
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ const { Wrapper } = makeWrapper()
+ const { useDeleteDevice } = await import('./hooks')
+ const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
+
+ await act(async () => {
+ await result.current.mutateAsync('test-uuid-1')
+ })
+
+ expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid: 'test-uuid-1' } },
+ })
+ })
+})
+
+describe('useTestReadDevice', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => {
+ mockPost.mockResolvedValue({
+ data: { ok: true, payload: { voltage: 230.2 } },
+ })
+
+ const { Wrapper } = makeWrapper()
+ const { useTestReadDevice } = await import('./hooks')
+ const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper })
+
+ await act(async () => {
+ await result.current.mutateAsync('test-uuid-1')
+ })
+
+ expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', {
+ params: { path: { uuid: 'test-uuid-1' } },
+ })
+ })
+})
diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts
new file mode 100644
index 0000000..d933005
--- /dev/null
+++ b/frontend/src/energy/hooks.ts
@@ -0,0 +1,114 @@
+/**
+ * Energy / Modbus hooks — typed TanStack Query wrappers for /api/modbus/*.
+ *
+ * All write operations go through the typed apiClient (openapi-fetch), which
+ * injects CSRF via the csrfMiddleware already wired into the client.
+ *
+ * Query-key conventions:
+ * ['modbus-devices'] — device list
+ * ['modbus-device', uuid] — single device
+ * ['modbus-profiles'] — profile list (rarely changes)
+ *
+ * On success, mutations invalidate the device list so the UI refreshes.
+ */
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import apiClient from '../api/client'
+import type { components } from '../api/schema.d.ts'
+
+// ---------------------------------------------------------------------------
+// Re-exported types for consumers
+// ---------------------------------------------------------------------------
+
+export type ModbusDevice = components['schemas']['ModbusDeviceResponse']
+export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
+export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
+export type ProfileSummary = components['schemas']['ProfileSummary']
+export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
+
+// ---------------------------------------------------------------------------
+// Query: list all devices
+// ---------------------------------------------------------------------------
+
+export function useDevices() {
+ return useQuery({
+ queryKey: ['modbus-devices'],
+ queryFn: async () => {
+ const res = await apiClient.GET('/api/modbus/devices')
+ return res.data
+ },
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Query: list available profiles
+// ---------------------------------------------------------------------------
+
+export function useProfiles() {
+ return useQuery({
+ queryKey: ['modbus-profiles'],
+ queryFn: async () => {
+ const res = await apiClient.GET('/api/modbus/profiles')
+ return res.data
+ },
+ // Profiles are static — 5 min stale time.
+ staleTime: 5 * 60 * 1000,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: create device
+// ---------------------------------------------------------------------------
+
+export function useCreateDevice() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (body: ModbusDeviceCreate) =>
+ apiClient.POST('/api/modbus/devices', { body }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: update (PATCH) device
+// ---------------------------------------------------------------------------
+
+export function useUpdateDevice() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ uuid, body }: { uuid: string; body: ModbusDeviceUpdate }) =>
+ apiClient.PATCH('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid } },
+ body,
+ }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: delete device
+// ---------------------------------------------------------------------------
+
+export function useDeleteDevice() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (uuid: string) =>
+ apiClient.DELETE('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid } },
+ }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: test-read device (POST /devices/{uuid}/test, does NOT persist)
+// ---------------------------------------------------------------------------
+
+export function useTestReadDevice() {
+ return useMutation({
+ mutationFn: (uuid: string) =>
+ apiClient.POST('/api/modbus/devices/{uuid}/test', {
+ params: { path: { uuid } },
+ }),
+ })
+}
diff --git a/frontend/src/pages/EnergyPage.test.tsx b/frontend/src/pages/EnergyPage.test.tsx
new file mode 100644
index 0000000..05c194a
--- /dev/null
+++ b/frontend/src/pages/EnergyPage.test.tsx
@@ -0,0 +1,343 @@
+/**
+ * Tests for EnergyPage (M5-T06).
+ *
+ * Coverage:
+ * 1. Renders device list from GET /api/modbus/devices.
+ * 2. Empty state when no devices.
+ * 3. "New Device" button opens DeviceForm modal.
+ * 4. Edit button opens DeviceForm modal pre-filled with device data.
+ * 5. Delete button opens confirmation modal; confirm calls DELETE.
+ * 6. Delete 409 error shows friendly hint in confirmation modal.
+ * 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
+ * 8. Test-read failure shows error in modal.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { renderWithProviders } from '../test-utils'
+import { EnergyPage } from './EnergyPage'
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const DEVICE = {
+ uuid: 'abc-123',
+ friendly_name: 'SDM120 Main',
+ transport: 'tcp',
+ host: '192.168.1.100',
+ port: 502,
+ unit_id: 1,
+ profile: 'sdm120',
+ poll_interval_s: 5,
+ enabled: true,
+ last_poll_at: '2026-06-22T10:00:00Z',
+ last_poll_ok: true,
+ created_at: '2026-06-01T00:00:00Z',
+ updated_at: '2026-06-01T00:00:00Z',
+}
+
+const PROFILES_RESP = {
+ profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
+}
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockDelete = vi.fn()
+const mockPatch = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ GET: (...args: unknown[]) => mockGet(...args),
+ POST: (...args: unknown[]) => mockPost(...args),
+ PATCH: (...args: unknown[]) => mockPatch(...args),
+ DELETE: (...args: unknown[]) => mockDelete(...args),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function setupDefaultMocks(devices = [DEVICE]) {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/api/modbus/devices') {
+ return Promise.resolve({ data: { items: devices, total: devices.length } })
+ }
+ if (path === '/api/modbus/profiles') {
+ return Promise.resolve({ data: PROFILES_RESP })
+ }
+ return Promise.resolve({ data: null })
+ })
+}
+
+function renderEnergy() {
+ return renderWithProviders(, { initialPath: '/energy' })
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('EnergyPage — device list', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('renders device list from GET /api/modbus/devices', async () => {
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('devices-table')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
+ expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
+ expect(screen.getByText('sdm120')).toBeInTheDocument()
+ })
+
+ it('shows empty state when no devices', async () => {
+ setupDefaultMocks([])
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('devices-empty')).toBeInTheDocument()
+ })
+ })
+
+ it('shows loading state initially', () => {
+ // Delay resolution so we can catch the loading state
+ mockGet.mockReturnValue(new Promise(() => {}))
+ renderEnergy()
+
+ expect(screen.getByTestId('energy-loading')).toBeInTheDocument()
+ })
+})
+
+describe('EnergyPage — create device', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('opens DeviceForm modal when New Device is clicked', async () => {
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-new-button')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('device-new-button'))
+
+ // Wait for both modal AND the form (which appears after profiles load)
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument()
+ expect(screen.getByTestId('device-host')).toBeInTheDocument()
+ })
+
+ it('submit on new device form calls POST /api/modbus/devices', async () => {
+ mockPost.mockResolvedValue({ data: DEVICE })
+ renderEnergy()
+
+ await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument())
+ fireEvent.click(screen.getByTestId('device-new-button'))
+
+ // Wait for profiles to load so the form is visible (profiles auto-select sdm120)
+ await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
+
+ // Fill required fields (profile is auto-selected to 'sdm120')
+ fireEvent.change(screen.getByTestId('device-friendly-name'), {
+ target: { value: 'Test Device' },
+ })
+ fireEvent.change(screen.getByTestId('device-host'), {
+ target: { value: '10.0.0.1' },
+ })
+
+ fireEvent.submit(screen.getByTestId('device-form'))
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith(
+ '/api/modbus/devices',
+ expect.objectContaining({
+ body: expect.objectContaining({
+ friendly_name: 'Test Device',
+ host: '10.0.0.1',
+ profile: 'sdm120',
+ }),
+ }),
+ )
+ })
+ })
+})
+
+describe('EnergyPage — edit device', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('opens DeviceForm modal with device data when Edit is clicked', async () => {
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
+ })
+
+ // Wait for profiles to load so form body is visible
+ await waitFor(() => {
+ expect(screen.getByTestId('device-form')).toBeInTheDocument()
+ })
+
+ // UUID shown in edit mode
+ expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid)
+ })
+})
+
+describe('EnergyPage — delete device', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('shows confirmation modal on Delete click', async () => {
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-delete-modal')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main')
+ })
+
+ it('calls DELETE on confirm and closes modal', async () => {
+ mockDelete.mockResolvedValue({ data: null })
+ renderEnergy()
+
+ await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
+ fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
+ await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByTestId('device-delete-confirm'))
+
+ await waitFor(() => {
+ expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
+ params: { path: { uuid: DEVICE.uuid } },
+ })
+ })
+ })
+
+ it('cancel button closes confirmation modal', async () => {
+ renderEnergy()
+
+ await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
+ fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
+ await waitFor(() => expect(screen.getByTestId('device-delete-cancel')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByTestId('device-delete-cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ it('shows friendly 409 hint when device has readings', async () => {
+ const { ApiError } = await import('../api/client')
+ mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' }))
+
+ renderEnergy()
+
+ await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
+ fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
+ await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByTestId('device-delete-confirm'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('EnergyPage — test-read', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('shows success payload modal after successful test-read', async () => {
+ mockPost.mockResolvedValue({
+ data: { ok: true, payload: { voltage: 230.2, current: 1.3 } },
+ })
+
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
+ expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
+ })
+
+ it('shows error modal after failed test-read', async () => {
+ mockPost.mockResolvedValue({
+ data: { ok: false, error: 'Connection timed out' },
+ })
+
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('test-read-error')).toBeInTheDocument()
+ expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
+ })
+})
diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx
new file mode 100644
index 0000000..d20ae45
--- /dev/null
+++ b/frontend/src/pages/EnergyPage.tsx
@@ -0,0 +1,396 @@
+/**
+ * EnergyPage — device management UI for the Energy (Modbus) domain.
+ *
+ * Features:
+ * - Device list with status indicators (enabled, last poll).
+ * - Create / edit via DeviceForm modal.
+ * - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
+ * - "Test read" button per device — calls POST /devices/{uuid}/test, shows
+ * decoded payload or error without persisting to the database.
+ *
+ * Out of scope (T07): readings cards, trend charts, Recharts import.
+ */
+
+import { useState } from 'react'
+import {
+ Container,
+ Title,
+ Table,
+ Button,
+ Group,
+ Text,
+ Loader,
+ Center,
+ Alert,
+ Stack,
+ Badge,
+ ScrollArea,
+ Modal,
+ Code,
+ Tooltip,
+} from '@mantine/core'
+import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
+import { DeviceForm } from '../energy/DeviceForm'
+import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
+import { ApiError } from '../api/client'
+
+// ---------------------------------------------------------------------------
+// Delete confirmation modal (with 409 "disable instead" hint)
+// ---------------------------------------------------------------------------
+
+interface ConfirmDeleteProps {
+ device: ModbusDevice
+ onConfirm: () => void
+ onCancel: () => void
+ loading: boolean
+ /** Set when the delete failed with 409. */
+ has409Error: boolean
+}
+
+function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
+ return (
+
+
+
+ Delete device {device.friendly_name}?
+
+
+ {has409Error && (
+
+ This device has existing readings and cannot be deleted. Consider{' '}
+ disabling it instead (click Edit → uncheck Enabled).
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Test-read result modal
+// ---------------------------------------------------------------------------
+
+interface TestResultModalProps {
+ result: ModbusTestReadResponse
+ deviceName: string
+ onClose: () => void
+}
+
+function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
+ return (
+
+ {result.ok ? (
+
+ Success
+
+ {JSON.stringify(result.payload, null, 2)}
+
+
+ ) : (
+
+ Failed
+
+ {result.error ?? 'Unknown error'}
+
+
+ )}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Device row action: test-read button (self-contained per row)
+// ---------------------------------------------------------------------------
+
+interface TestReadButtonProps {
+ device: ModbusDevice
+}
+
+function TestReadButton({ device }: TestReadButtonProps) {
+ const testMutation = useTestReadDevice()
+ const [result, setResult] = useState(null)
+
+ async function handleTest() {
+ setResult(null)
+ const res = await testMutation.mutateAsync(device.uuid)
+ if (res.data) {
+ setResult(res.data)
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+ {result && (
+ setResult(null)}
+ />
+ )}
+ >
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Device list table
+// ---------------------------------------------------------------------------
+
+interface DeviceTableProps {
+ devices: ModbusDevice[]
+ onEdit: (device: ModbusDevice) => void
+ onDelete: (device: ModbusDevice) => void
+}
+
+function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
+ if (devices.length === 0) {
+ return (
+
+ No devices configured yet. Click "New Device" to add one.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Name
+ Host
+ Port
+ Unit ID
+ Profile
+ Poll (s)
+ Status
+ Actions
+
+
+
+ {devices.map((device) => (
+
+
+
+ {device.friendly_name}
+
+
+ {device.host}
+ {device.port}
+ {device.unit_id}
+
+
+ {device.profile}
+
+
+ {device.poll_interval_s}
+
+
+
+ {device.enabled ? 'enabled' : 'disabled'}
+
+ {device.last_poll_ok !== null && (
+
+ {device.last_poll_ok ? 'online' : 'offline'}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// EnergyPage — top-level
+// ---------------------------------------------------------------------------
+
+export function EnergyPage() {
+ const devicesQuery = useDevices()
+ const deleteMutation = useDeleteDevice()
+
+ const [editDevice, setEditDevice] = useState(null)
+ const [showCreateForm, setShowCreateForm] = useState(false)
+ const [deleteDevice, setDeleteDevice] = useState(null)
+ const [delete409, setDelete409] = useState(false)
+
+ function openCreate() {
+ setEditDevice(null)
+ setShowCreateForm(true)
+ }
+
+ function openEdit(device: ModbusDevice) {
+ setShowCreateForm(false)
+ setEditDevice(device)
+ }
+
+ function openDelete(device: ModbusDevice) {
+ setDeleteDevice(device)
+ setDelete409(false)
+ }
+
+ function closeDelete() {
+ setDeleteDevice(null)
+ setDelete409(false)
+ }
+
+ async function handleDeleteConfirm() {
+ if (!deleteDevice) return
+ setDelete409(false)
+ try {
+ await deleteMutation.mutateAsync(deleteDevice.uuid)
+ closeDelete()
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 409) {
+ setDelete409(true)
+ }
+ // Leave modal open so user sees the hint.
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Loading / error states
+ // ---------------------------------------------------------------------------
+
+ if (devicesQuery.isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (devicesQuery.isError || !devicesQuery.data) {
+ return (
+
+
+ Failed to load devices. Please refresh.
+
+
+ )
+ }
+
+ const devices = devicesQuery.data.items
+
+ // ---------------------------------------------------------------------------
+ // Main render
+ // ---------------------------------------------------------------------------
+
+ return (
+
+
+
+ Energy — Devices
+
+
+
+
+
+
+ {/* Create form */}
+ {showCreateForm && (
+ setShowCreateForm(false)}
+ onSaved={() => setShowCreateForm(false)}
+ />
+ )}
+
+ {/* Edit form */}
+ {editDevice && (
+ setEditDevice(null)}
+ onSaved={() => setEditDevice(null)}
+ />
+ )}
+
+ {/* Delete confirmation */}
+ {deleteDevice && (
+
+ )}
+
+ )
+}