Complete IoT monitoring platform for Acuvim II power meters via ESP32. Firmware (Phases 1-7): - ESP32-WROVER-B (TTGO T-Call v1.4) with RS485 Modbus RTU - WiFi STA+AP concurrent mode with GSM/GPRS failover - Transport abstraction layer with 4 priority modes - MQTT protocol with 20 commands, LWT, QoS, exponential backoff - SD card offline buffering with JSONL rotation and non-blocking drain - OTA firmware updates with dual partition rollback protection - Watchdog timer, crash loop detection, Acuvim health monitoring - Captive portal provisioning with AP mode Console backend (Phase 8): - .NET 10 minimal API with PostgreSQL + EF Core - JWT authentication, SignalR real-time updates - MQTTnet 5.x bridge service with health monitoring - Device, telemetry, firmware, alert, group management - Rate limiting, security headers, Swagger/OpenAPI Frontend (Phase 9): - React 18 + TypeScript + Vite with Ant Design 5 - ECharts telemetry visualization, TanStack Query - SignalR live updates, device management UI - Dashboard, fleet management, firmware deployment Testing & Production (Phase 10): - 28 firmware unit tests (Modbus, JSON, config, version) - 23 xUnit backend tests (device, telemetry, command, alert) - Docker Compose with nginx, TLS MQTT, PostgreSQL - Production deployment, commissioning, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
Phase 8: Console Application Backend
Objective
Build the server-side backend for the console application that manages the ESP32 device fleet. The backend handles device registration, configuration management, telemetry ingestion, OTA firmware distribution, health monitoring, and exposes a REST API for the frontend (Phase 9).
Prerequisites
- Phase 7 complete (device firmware feature-complete)
- Server/VPS for hosting (Linux recommended)
- Domain name (optional, can use IP for development)
- PostgreSQL database
- Mosquitto MQTT broker
Deliverables
- .NET 10 Web API application
- PostgreSQL database schema and migrations
- MQTT client bridge (subscribe to device topics, publish commands)
- REST API for frontend and external integrations
- Firmware binary hosting and version management
- Authentication and authorization
- Deployment configuration (Docker)
8.1 Technology Stack
| Component | Technology | Justification |
|---|---|---|
| Runtime | .NET 10 | Strong typing, high performance, built-in DI |
| Web Framework | ASP.NET Core Minimal API | Lightweight, fast, less boilerplate |
| Database | PostgreSQL 16 | Robust, JSON support, time-series extensions |
| ORM | Entity Framework Core 8 | Migrations, LINQ queries, strong typing |
| MQTT Client | MQTTnet | .NET-native, high-performance MQTT v5 client |
| Auth | ASP.NET Identity + JWT | Built-in user management, token-based API auth |
| File Storage | Local filesystem + optional S3 | Firmware binaries |
| Caching | In-memory + optional Redis | Device status, frequent lookups |
| Logging | Serilog | Structured logging |
| API Docs | Swagger / OpenAPI | Auto-generated API documentation |
| Containerization | Docker + Docker Compose | Reproducible deployment |
8.2 Project Structure
console/
├── src/
│ ├── Tau.Acuvim.Console/ # Main Web API project
│ │ ├── Program.cs
│ │ ├── appsettings.json
│ │ ├── Controllers/ # API endpoints (if using controllers)
│ │ ├── Endpoints/ # Minimal API endpoint groups
│ │ │ ├── DeviceEndpoints.cs
│ │ │ ├── TelemetryEndpoints.cs
│ │ │ ├── FirmwareEndpoints.cs
│ │ │ ├── ConfigEndpoints.cs
│ │ │ ├── AlertEndpoints.cs
│ │ │ └── AuthEndpoints.cs
│ │ ├── Services/
│ │ │ ├── MqttBridgeService.cs # MQTT client (background service)
│ │ │ ├── DeviceService.cs
│ │ │ ├── TelemetryService.cs
│ │ │ ├── FirmwareService.cs
│ │ │ ├── CommandService.cs
│ │ │ ├── HealthMonitorService.cs
│ │ │ └── AlertService.cs
│ │ ├── Models/
│ │ │ ├── Device.cs
│ │ │ ├── TelemetryRecord.cs
│ │ │ ├── FirmwareVersion.cs
│ │ │ ├── DeviceConfig.cs
│ │ │ ├── Alert.cs
│ │ │ ├── Command.cs
│ │ │ └── User.cs
│ │ ├── Data/
│ │ │ ├── AppDbContext.cs
│ │ │ └── Migrations/
│ │ ├── DTOs/ # Request/response models
│ │ ├── Middleware/
│ │ │ └── ApiKeyMiddleware.cs
│ │ └── Hubs/
│ │ └── DeviceHub.cs # SignalR for real-time UI updates
│ │
│ └── Tau.Acuvim.Console.Tests/ # Unit and integration tests
│
├── docker-compose.yml
├── Dockerfile
└── README.md
8.3 Database Schema
Entity Relationship
Devices ──< TelemetryRecords
Devices ──< Alerts
Devices ──< Commands
Devices ──< DeviceConfigs (1:1 current, history)
FirmwareVersions (standalone)
Users (standalone)
Tables
devices
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id VARCHAR(32) UNIQUE NOT NULL, -- e.g., "ACV-AABBCCDDEEFF"
name VARCHAR(128), -- Friendly name
description TEXT,
mac_address VARCHAR(17),
imei VARCHAR(15),
iccid VARCHAR(20),
hardware VARCHAR(64), -- "TTGO T-Call v1.4"
firmware_version VARCHAR(16),
capabilities JSONB, -- ["wifi","gsm","sd","modbus"]
status VARCHAR(16) DEFAULT 'offline', -- online, offline, degraded
last_heartbeat TIMESTAMPTZ,
last_telemetry TIMESTAMPTZ,
connection_type VARCHAR(8), -- wifi, gsm
signal_strength INTEGER,
ip_address VARCHAR(45),
boot_count INTEGER DEFAULT 0,
group_id UUID REFERENCES device_groups(id),
tags JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_devices_device_id ON devices(device_id);
CREATE INDEX idx_devices_status ON devices(status);
telemetry_records
CREATE TABLE telemetry_records (
id BIGSERIAL PRIMARY KEY,
device_id VARCHAR(32) NOT NULL REFERENCES devices(device_id),
timestamp TIMESTAMPTZ NOT NULL,
data JSONB NOT NULL, -- Full telemetry payload
source VARCHAR(8) DEFAULT 'live', -- live, sd (buffered)
connection VARCHAR(8), -- wifi, gsm
received_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_telemetry_device_ts ON telemetry_records(device_id, timestamp DESC);
CREATE INDEX idx_telemetry_timestamp ON telemetry_records(timestamp DESC);
-- Partitioning by month (for large-scale deployments)
-- Consider TimescaleDB extension for automatic time-series management
alerts
CREATE TABLE alerts (
id BIGSERIAL PRIMARY KEY,
device_id VARCHAR(32) NOT NULL REFERENCES devices(device_id),
alert_type VARCHAR(32) NOT NULL, -- overvoltage, modbus_loss, etc.
severity VARCHAR(16) NOT NULL, -- info, warning, critical
message TEXT NOT NULL,
value DOUBLE PRECISION,
threshold DOUBLE PRECISION,
metadata JSONB, -- Additional context
acknowledged BOOLEAN DEFAULT FALSE,
acknowledged_by VARCHAR(128),
acknowledged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_alerts_device ON alerts(device_id, created_at DESC);
CREATE INDEX idx_alerts_unresolved ON alerts(device_id) WHERE resolved_at IS NULL;
commands
CREATE TABLE commands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id VARCHAR(32) NOT NULL REFERENCES devices(device_id),
request_id VARCHAR(64) UNIQUE NOT NULL,
command VARCHAR(32) NOT NULL,
params JSONB,
status VARCHAR(16) DEFAULT 'pending', -- pending, sent, ack, success, error, timeout
response JSONB,
created_by VARCHAR(128),
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_commands_device ON commands(device_id, created_at DESC);
CREATE INDEX idx_commands_pending ON commands(status) WHERE status IN ('pending', 'sent');
firmware_versions
CREATE TABLE firmware_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version VARCHAR(16) UNIQUE NOT NULL, -- "1.2.0"
filename VARCHAR(128) NOT NULL,
file_path VARCHAR(256) NOT NULL,
file_size INTEGER NOT NULL,
checksum VARCHAR(128) NOT NULL, -- SHA256
release_notes TEXT,
hardware VARCHAR(64), -- Target hardware (null = all)
mandatory BOOLEAN DEFAULT FALSE,
active BOOLEAN DEFAULT TRUE, -- Available for deployment
uploaded_by VARCHAR(128),
created_at TIMESTAMPTZ DEFAULT NOW()
);
device_groups
CREATE TABLE device_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(64) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
8.4 MQTT Bridge Service
MqttBridgeService.cs
A background service that maintains an MQTT connection to the broker, subscribes to all device topics, processes incoming messages, and publishes commands.
public class MqttBridgeService : BackgroundService
{
// Subscribe to:
// acuvim/+/telemetry -> TelemetryService.Ingest()
// acuvim/+/heartbeat -> HealthMonitorService.ProcessHeartbeat()
// acuvim/+/resp -> CommandService.ProcessResponse()
// acuvim/+/status -> DeviceService.UpdateStatus()
// acuvim/+/alerts -> AlertService.ProcessAlert()
// devices/register -> DeviceService.Register()
// Publish to:
// acuvim/{device_id}/cmd -> CommandService.Send()
}
Topic Routing
private async Task HandleMessage(MqttApplicationMessage msg)
{
var topic = msg.Topic;
var payload = Encoding.UTF8.GetString(msg.PayloadSegment);
// Parse topic: acuvim/{device_id}/{type}
var parts = topic.Split('/');
if (topic == "devices/register")
{
await _deviceService.Register(payload);
return;
}
if (parts.Length >= 3)
{
var deviceId = parts[1];
var type = parts[2];
switch (type)
{
case "telemetry":
await _telemetryService.Ingest(deviceId, payload);
break;
case "heartbeat":
await _healthMonitor.ProcessHeartbeat(deviceId, payload);
break;
case "resp":
await _commandService.ProcessResponse(deviceId, payload);
break;
case "status":
await _deviceService.UpdateConnectionStatus(deviceId, payload);
break;
case "alerts":
await _alertService.ProcessAlert(deviceId, payload);
break;
}
}
}
8.5 REST API Endpoints
Devices
GET /api/devices List all devices (with filtering, pagination)
GET /api/devices/{deviceId} Get device details
PUT /api/devices/{deviceId} Update device (name, description, group, tags)
DELETE /api/devices/{deviceId} Remove device from console
GET /api/devices/{deviceId}/status Get current device status
POST /api/devices/{deviceId}/command Send command to device
GET /api/devices/{deviceId}/commands Command history
Telemetry
GET /api/devices/{deviceId}/telemetry Latest telemetry
GET /api/devices/{deviceId}/telemetry/history Historical data (with time range, aggregation)
GET /api/devices/{deviceId}/telemetry/export Export as CSV
Firmware
GET /api/firmware List all firmware versions
POST /api/firmware Upload new firmware version
GET /api/firmware/{version} Get firmware details
DELETE /api/firmware/{version} Delete firmware version
GET /api/firmware/download/{version} Download firmware binary (used by devices)
GET /api/firmware/check Check for update (used by devices)
POST /api/firmware/deploy Deploy firmware to device(s)
Alerts
GET /api/alerts List alerts (with filtering)
GET /api/devices/{deviceId}/alerts Device-specific alerts
PUT /api/alerts/{id}/acknowledge Acknowledge alert
Configuration
GET /api/devices/{deviceId}/config Get device configuration
POST /api/devices/{deviceId}/config Push configuration to device
GET /api/devices/{deviceId}/config/wifi/scan Trigger WiFi scan on device
Groups
GET /api/groups List device groups
POST /api/groups Create group
PUT /api/groups/{id} Update group
DELETE /api/groups/{id} Delete group
POST /api/groups/{id}/command Send command to all devices in group
POST /api/groups/{id}/deploy Deploy firmware to group
Auth
POST /api/auth/login Login, get JWT token
POST /api/auth/register Register new user (admin only)
POST /api/auth/refresh Refresh JWT token
GET /api/auth/me Get current user info
Dashboard
GET /api/dashboard/summary Fleet summary (total, online, offline, alerts)
GET /api/dashboard/stats Aggregate statistics
8.6 Command Service Flow
Sending a Command
1. Frontend calls POST /api/devices/{deviceId}/command
Body: { "command": "wifi_scan" }
2. CommandService generates request_id, saves to DB (status: pending)
3. CommandService publishes to MQTT: acuvim/{deviceId}/cmd
4. Device receives command, processes, publishes to acuvim/{deviceId}/resp
5. MqttBridge receives response
6. CommandService matches request_id, updates DB (status: success, response: {...})
7. SignalR notifies frontend of command completion
8. If no response in 60 seconds: mark as timeout
Command Timeout
public class CommandTimeoutService : BackgroundService
{
// Every 30 seconds, check for commands where:
// status == 'sent' AND sent_at < (now - 60 seconds)
// Mark as 'timeout'
}
8.7 Health Monitor Service
HealthMonitorService.cs
Background service that monitors device health based on heartbeats:
public class HealthMonitorService : BackgroundService
{
// Every 30 seconds:
// - Check all devices where last_heartbeat < (now - 3 * heartbeat_interval)
// -> Mark as "degraded"
// - Check all devices where last_heartbeat < (now - 5 * heartbeat_interval)
// -> Mark as "offline"
// - Optionally send notification (email, webhook) for offline devices
public async Task ProcessHeartbeat(string deviceId, string payload)
{
// Parse heartbeat JSON
// Update device: status = "online", last_heartbeat, firmware_version,
// connection_type, signal_strength, ip_address, boot_count
// Update health metrics
// Check for anomalies (high error rate, low memory, brownout resets)
// Notify frontend via SignalR
}
}
8.8 Firmware Service
Upload Flow
public async Task<FirmwareVersion> Upload(IFormFile file, string version,
string releaseNotes, bool mandatory)
{
// 1. Validate file is a valid ESP32 binary (check magic bytes)
// 2. Calculate SHA256 checksum
// 3. Save to /firmware/{version}/firmware.bin
// 4. Create DB record
// 5. Return firmware version info
}
Deployment Flow
public async Task Deploy(string version, List<string> deviceIds)
{
var firmware = await GetVersion(version);
foreach (var deviceId in deviceIds)
{
await _commandService.Send(deviceId, "ota_update", new {
url = $"{_baseUrl}/api/firmware/download/{version}",
version = firmware.Version,
checksum = firmware.Checksum
});
}
}
Device Check Endpoint
// GET /api/firmware/check?device_id=X¤t_version=Y&hardware=Z
public async Task<FirmwareCheckResponse> Check(string deviceId,
string currentVersion, string hardware)
{
var latest = await _db.FirmwareVersions
.Where(f => f.Active)
.Where(f => f.Hardware == null || f.Hardware == hardware)
.OrderByDescending(f => f.CreatedAt)
.FirstOrDefaultAsync();
if (latest != null && IsNewer(latest.Version, currentVersion))
{
return new FirmwareCheckResponse
{
UpdateAvailable = true,
Version = latest.Version,
Url = $"{_baseUrl}/api/firmware/download/{latest.Version}",
Size = latest.FileSize,
Checksum = latest.Checksum,
ReleaseNotes = latest.ReleaseNotes,
Mandatory = latest.Mandatory
};
}
return new FirmwareCheckResponse { UpdateAvailable = false };
}
8.9 Real-Time Updates (SignalR)
DeviceHub.cs
SignalR hub for pushing real-time updates to the frontend:
public class DeviceHub : Hub
{
// Methods the server calls on clients:
// - DeviceStatusChanged(deviceId, status)
// - TelemetryReceived(deviceId, data)
// - HeartbeatReceived(deviceId, data)
// - AlertCreated(deviceId, alert)
// - CommandCompleted(deviceId, requestId, response)
// - DeviceRegistered(device)
}
The frontend subscribes to these events for live dashboard updates without polling.
8.10 Authentication
JWT-Based Auth
- Users register/login to get a JWT token
- Token included in
Authorization: Bearer <token>header - Token expiry: 24 hours
- Refresh token: 7 days
- Roles:
Admin,Operator,Viewer
Device API Key
Devices authenticate firmware downloads using their device_id as an API key or via MQTT credentials. The firmware check/download endpoints require either a valid JWT or a valid device_id.
8.11 Docker Deployment
docker-compose.yml
version: '3.8'
services:
console:
build: .
ports:
- "5000:5000"
environment:
- ConnectionStrings__DefaultConnection=Host=db;Database=acuvim;Username=acuvim;Password=secret
- Mqtt__Broker=mqtt
- Mqtt__Port=1883
depends_on:
- db
- mqtt
volumes:
- firmware-data:/app/firmware
db:
image: postgres:16
environment:
- POSTGRES_DB=acuvim
- POSTGRES_USER=acuvim
- POSTGRES_PASSWORD=secret
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
mqtt:
image: eclipse-mosquitto:2
ports:
- "1883:1883"
- "9001:9001"
volumes:
- mosquitto-data:/mosquitto/data
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
volumes:
postgres-data:
mosquitto-data:
firmware-data:
8.12 Configuration
appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=acuvim;Username=acuvim;Password=secret"
},
"Mqtt": {
"Broker": "localhost",
"Port": 1883,
"Username": "console",
"Password": "",
"TopicPrefix": "acuvim",
"ClientId": "acuvim-console"
},
"Firmware": {
"StoragePath": "./firmware",
"BaseUrl": "https://console.example.com"
},
"Jwt": {
"Secret": "your-secret-key-min-32-chars-long-here",
"Issuer": "Tau.Acuvim.Console",
"ExpiryHours": 24
},
"HealthMonitor": {
"CheckIntervalSeconds": 30,
"DegradedThresholdMultiplier": 3,
"OfflineThresholdMultiplier": 5
},
"Telemetry": {
"RetentionDays": 365,
"CleanupIntervalHours": 24
}
}
8.13 Testing & Validation
| Test | Method | Pass Criteria |
|---|---|---|
| API starts | dotnet run |
Swagger UI at /swagger |
| DB migration | dotnet ef database update |
Tables created |
| MQTT connection | Start broker, run console | MQTT connected, subscribed |
| Device registration | Publish registration via MQTT | Device appears in DB |
| Telemetry ingest | Publish telemetry via MQTT | Record saved, queryable via API |
| Heartbeat processing | Publish heartbeat | Device status updated |
| Health monitor | Stop device heartbeats | Device marked degraded then offline |
| Send command | POST /api/devices/{id}/command | Command published to MQTT, response received |
| Firmware upload | POST /api/firmware with binary | File saved, DB record created |
| Firmware check | GET /api/firmware/check | Correct update response |
| Firmware download | GET /api/firmware/download/{v} | Binary downloaded |
| Deploy firmware | POST /api/firmware/deploy | OTA command sent to devices |
| Auth login | POST /api/auth/login | JWT token returned |
| Auth protected | GET /api/devices without token | 401 Unauthorized |
| SignalR | Connect from browser | Real-time events received |
| Docker | docker-compose up |
All services start, communicate |
| Alert processing | Publish alert via MQTT | Alert saved, queryable |
8.14 Phase 8 Completion Criteria
- .NET 10 Web API project builds and runs
- PostgreSQL schema migrated, all tables created
- MQTT bridge subscribes to all device topics
- Device registration works (MQTT -> DB)
- Telemetry ingestion works (MQTT -> DB -> API)
- Heartbeat processing updates device status
- Health monitor marks devices degraded/offline
- Command send/receive flow complete
- Firmware upload, check, and download working
- Firmware deployment (push OTA via MQTT)
- Alert processing and querying
- JWT authentication on all endpoints
- SignalR real-time updates
- Docker Compose deployment working
- API documented via Swagger/OpenAPI
Previous Phase: Phase 7 — Heartbeat, Health & Registration Next Phase: Phase 9 — Console Application Frontend