379 lines
16 KiB
Markdown
379 lines
16 KiB
Markdown
# Architecture & Spec — PlcVanguard
|
||
|
||
## System Overview
|
||
|
||
PlcVanguard is a cross-platform .NET 8 application for industrial communication with Siemens S7 PLCs. It continuously polls configured memory areas, triggers on-event reads on state-bit detection, and persists data to multiple targets (File, Redis, MariaDB). A Blazor dashboard provides live monitoring and historical comparison of PLC data.
|
||
|
||
The codebase inherits production-proven logic from MHT-Siemens (legacy .NET Framework WinForms) and restructures it into a modular, testable, cross-platform architecture.
|
||
|
||
## Phase Completion Status
|
||
|
||
| Phase | Description | Status |
|
||
|-------|-------------|--------|
|
||
| 1 | Solution structure, .NET 8 project scaffold | ✅ Done |
|
||
| 2 | Core PLC communication (S7.net 0.2.0 driver) | ✅ Done |
|
||
| 3 | Continuous poller (background task) | ✅ Done |
|
||
| 4 | On-event poller (state-bit detection) | ✅ Done |
|
||
| 5 | PlcHostedService (lifecycle management) | ✅ Done |
|
||
| 6 | State Manager | ✅ Done |
|
||
| 7 | Persistence pipeline (File, Redis, MariaDB) | ✅ Done |
|
||
| 8 | Blazor Web UI scaffold | ✅ Done |
|
||
| 9 | Dashboard page with live monitoring | ✅ Done |
|
||
| 10 | Comparison page with SVG chart + reference line | ✅ Done |
|
||
| 11 | REST API Controllers | ✅ Done |
|
||
| 12 | SignalR Hub for real-time push | ✅ Done |
|
||
| 13 | EF Core database migration / seeding | ✅ Done |
|
||
| 14 | State/Records Blazor pages | ✅ Done |
|
||
| 15 | Unit Tests (StateManager events) | ✅ Done |
|
||
| 16 | Production config (appsettings.Production.json) | ✅ Done |
|
||
| 17 | Auth Middleware with Login/Logout Razor Pages | ✅ Done |
|
||
| 18 | Core PLC → Web wiring (S7PlcService, StateManager integration) | ✅ Done |
|
||
| 19 | SignalR real-time push (PlcHub, PlcStateBroadcaster, SignalRConnectionService) | ✅ Done |
|
||
| 20 | Unit tests for StateManager events | ✅ Done |
|
||
| 21 | Persistence targets wiring (File, MariaDB into PlcStateBroadcaster) | ✅ Done |
|
||
| 22 | RecordsService bridges Core persistence to Web API | ✅ Done |
|
||
| 23 | PlcPollerService IHostedService (polls PLC → StateManager) | ✅ Done |
|
||
| 24 | Aggregation pipeline: StateManager → PlcStateBroadcaster → Persistence targets | ✅ Done |
|
||
| 25 | OnEventTriggerService (DB100 flag 0→1 triggers DB101 1000-READ read → persist) | ✅ Done |
|
||
|
||
## What We Have Today
|
||
|
||
### Build Status
|
||
- **Compiler**: 0 errors, 0 warnings
|
||
- **Tests**: 12/12 passing (xUnit)
|
||
- **Framework**: .NET 8 (net8.0) Blazor Server + Class Library
|
||
|
||
### Architecture Status
|
||
All 25 phases complete. The system is fully wired:
|
||
|
||
```
|
||
S7 PLC ──► S7PlcService (S7.net 0.2.0) ──► StateManager (in-memory, thread-safe)
|
||
│
|
||
┌───────────────┼───────────────┐
|
||
│ │ │
|
||
┌─────────▼────┐ ┌─────▼──────┐ ┌─────▼──────────┐
|
||
│PlcPoller │ │OnEvent │ │SignalR Push │
|
||
│(IHostedSvc) │ │Trigger │ │(PlcStateBrdcr) │
|
||
│Polls 3 areas │ │(IHostedSvc) │ │via PlcHub │
|
||
│Every 1000ms │ │DB100→DB101 │ │to Blazor │
|
||
└──────────────┘ └──────────────┘ └────────────────┘
|
||
│
|
||
┌───────────────┼───────────────┐
|
||
│ │ │
|
||
┌─────────▼────┐ ┌─────▼──────┐ ┌─────▼──────────┐
|
||
│ SignalR │ │ File │ │ MariaDB │
|
||
│ Hub/Client │ │ Persist │ │ (EF Core) │
|
||
│→ Dashboard │ │ (JSON file)│ │ (Pomelo) │
|
||
└──────────────┘ └────────────┘ └────────────────┘
|
||
│
|
||
REST APIs:
|
||
GET /api/state ← PLC state
|
||
POST /api/state/connect ← Connect/Disconnect
|
||
GET /api/records ← Historical data
|
||
GET /api/records/recent← Recent records
|
||
```
|
||
|
||
### Core Components (100% wired)
|
||
|
||
| Layer | Component | Status | Details |
|
||
|-------|-----------|--------|---------|
|
||
| **PLC Driver** | `S7PlcService` | ✅ | S7.net 0.2.0, auto-connect, read/write REALs |
|
||
| | `ReadDb101Async()` | ✅ | Reads 1000 REAL values from DB101 (4000 bytes) |
|
||
| **State** | `StateManager` | ✅ | Thread-safe Dictionarystores values, raises events |
|
||
| | `ValueChanged` event | ✅ | Fires on every value update |
|
||
| | `ConnectionStateChanged` event | ✅ | Fires on connect/disconnect changes |
|
||
| **Polling** | `PlcPollerService` | ✅ | IHostedService, polls PLC → StateManager every 1s, auto-reconnect on error |
|
||
| **On-Event** | `OnEventTriggerService` | ✅ | IHostedService, watches DB100.DB0 bit 0, detects 0→1 transition, triggers DB101 read |
|
||
| | `PlcDb101Reader` | ✅ | Reads 1000 REALs from DB101, serializes to JSON for persistence |
|
||
| **Persistence** | `FilePersistence` | ✅ | Saves PlcRecord as JSON file with rotation |
|
||
| | `MariaDbPersistence` | ✅ | EF Core → Pomelo MySQL, auto-migrate on startup |
|
||
| | `RedisPersistence` | ✅ | In Core but not registered in DI (opt-in) |
|
||
| **SignalR** | `IHubContext<PlcHub>` | ✅ | Pushes StateUpdated/NewRecord events to all connected clients |
|
||
| | `SignalRConnectionService` | ✅ | Client-side HubConnection with auto-reconnect |
|
||
| | Dashboard.razor | ✅ | Subscribes to SignalR, receives live updates, reorders SVG trendline |
|
||
| **Web API** | `StateController` | ✅ | GET/POST /api/state (connect/disconnect/trend) |
|
||
| | `RecordsController` | ✅ | GET /api/records (full/recent/total/compare) |
|
||
| **Web Services** | `PLCService` | ✅ | Bridges PLCService + StateManager for Blazor pages |
|
||
| | `RecordsService` | ✅ | Reads all persistence targets, returns merged/ordered PlcRecord list |
|
||
| **Auth** | Cookie Authentication | ✅ | Login/Logout Razor Pages, AuthPolicy, AuthService |
|
||
| **Blazor Pages** | Dashboard.razor | ✅ | Live counters, trendline SVG, history table, PLC connect/disconnect |
|
||
| | Comparison.razor | ✅ | Multi-record comparison with SVG chart |
|
||
| | State.razor | ✅ | PLC state display |
|
||
| | Records.razor | ✅ | Historical records browsing |
|
||
| **Config** | `ConnParam` | ✅ | PLC IP, CPU type, rack, slot |
|
||
| | `AppSettings` | ✅ | SampleTimerMs, SetupJsonPath, AppName |
|
||
| | `OnEventConfig` | ✅ | TriggerDB, TargetDB, bit position, interval |
|
||
|
||
### Git History
|
||
- `origin/develop`: 10 commits, all pushed
|
||
- `develop`: up-to-date with origin, clean working tree
|
||
|
||
## Component Architecture (Mermaid)
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph PlcVanguard.Web["PlcVanguard.Web (ASP.NET Core Blazor Server)"]
|
||
API[REST API Controller]
|
||
Dashboard[Blazor Dashboard Pages]
|
||
HostSvc[HostedService / BackgroundService]
|
||
SignalR[SignalR Hub]
|
||
auth[Auth Middleware]
|
||
end
|
||
|
||
subgraph PlcVanguard.Core["PlcVanguard.Core (Class Library)"]
|
||
PLC[PLC Service / IPLCService]
|
||
S7[S7netplus Driver]
|
||
Poller[Continuous Poller]
|
||
OnEv[OnEvent Poller]
|
||
State[State Manager]
|
||
CD[ConcurrentDictionary<K, PLCData>]
|
||
Persist[Persistence Pipeline]
|
||
FS[File Persistence]
|
||
Redis[Redis Persistence]
|
||
DB[MariaDB / EF Core Persistence]
|
||
Config[Configuration System]
|
||
Log[MS Logging]
|
||
end
|
||
|
||
PLC --> S7
|
||
PLC -.-> Poller
|
||
PLC -.-> OnEv
|
||
Poller --> State
|
||
OnEv --> State
|
||
State --> CD
|
||
State --> Persist
|
||
Persist --> FS
|
||
Persist --> Redis
|
||
Persist --> DB
|
||
HostSvc --> PLC
|
||
HostSvc --> Config
|
||
API --> State
|
||
SignalR --> State
|
||
Dashboard --> SignalR
|
||
auth --> API
|
||
```
|
||
|
||
## Data Model
|
||
|
||
```csharp
|
||
// Memory area configuration
|
||
public class MemoryArea
|
||
{
|
||
public string Id { get; set; } = ""; // Unique identifier
|
||
public int DbNumber { get; set; } // DB number (e.g., 100, 101)
|
||
public int StartAddress { get; set; } // Byte offset
|
||
public int SizeBytes { get; set; } // Total byte size
|
||
public DataType DataType { get; set; } // REAL, BYTE, INT, etc.
|
||
public ReadMode Mode { get; set; } = ReadMode.Continuous; // Continuous | OnEvent
|
||
public int PollIntervalMs { get; set; } = 1000; // For Continuous mode
|
||
public string? OnEventCondition { get; set; } // Trigger description
|
||
}
|
||
|
||
// PLC connection parameters
|
||
public class PlcConnectionConfig
|
||
{
|
||
public string IpAddress { get; set; } = "";
|
||
public CpuType CpuType { get; set; } = CpuType.S71500;
|
||
public int Rack { get; set; } = 0;
|
||
public int Slot { get; set; } = 1;
|
||
}
|
||
|
||
// Parsed data value from PLC
|
||
public class PLCReadResult
|
||
{
|
||
public string AreaId { get; set; } = "";
|
||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||
public byte[] RawBytes { get; set; } = Array.Empty<byte>();
|
||
public List<double> RealValues { get; set; } = new();
|
||
public bool Success { get; set; }
|
||
public string? Error { get; set; }
|
||
}
|
||
|
||
// Historical record (DB101 one-shot read triggered by DB100 flag)
|
||
public class PLCRecord
|
||
{
|
||
public long Id { get; set; } // Incremental unique ID
|
||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||
public int SourceDbId { get; set; } // Which DB triggered
|
||
public byte[] Payload { get; set; } = Array.Empty<byte>();
|
||
public List<double> RealValues { get; set; } = new();
|
||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||
}
|
||
|
||
// Current state snapshot (one entry per configured area)
|
||
public class PLCStateSnapshot
|
||
{
|
||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||
public Dictionary<string, PLCReadResult> Areas { get; set; } = new();
|
||
}
|
||
```
|
||
|
||
## Configuration Schema
|
||
|
||
```json
|
||
{
|
||
"PLC": {
|
||
"IpAddress": "192.168.0.102",
|
||
"CpuType": "S71500",
|
||
"Rack": 0,
|
||
"Slot": 1
|
||
},
|
||
"Areas": [
|
||
{
|
||
"Id": "DB100",
|
||
"DbNumber": 100,
|
||
"StartAddress": 0,
|
||
"SizeBytes": 32,
|
||
"Mode": "Continuous",
|
||
"PollIntervalMs": 1000,
|
||
"DataType": "Mixed"
|
||
},
|
||
{
|
||
"Id": "DB101",
|
||
"DbNumber": 101,
|
||
"StartAddress": 0,
|
||
"SizeBytes": 4000,
|
||
"Mode": "OnEvent",
|
||
"OnEventCondition": "DB100.Byte0.Bit0",
|
||
"DataType": "REAL_ARRAY"
|
||
}
|
||
],
|
||
"Persistence": {
|
||
"FileSystem": {
|
||
"Enabled": true,
|
||
"Path": "data",
|
||
"RotationSizeMB": 10,
|
||
"Format": "JSON"
|
||
},
|
||
"Redis": {
|
||
"Enabled": false,
|
||
"ConnectionString": "localhost:6379"
|
||
},
|
||
"MariaDB": {
|
||
"Enabled": false,
|
||
"ConnectionString": "Server=localhost;Database=plcvanguard;Uid=root;Pwd=;",
|
||
"AutoMigrate": true
|
||
}
|
||
},
|
||
"Polling": {
|
||
"ContinuousGapMs": 100,
|
||
"ReconnectDelayMs": 5000,
|
||
"MaxReconnectAttempts": -1
|
||
},
|
||
"Logging": {
|
||
"Level": "Information",
|
||
"FilePath": "logs/plcvanguard.log"
|
||
}
|
||
}
|
||
```
|
||
|
||
## DB100 / DB101 Specific Logic
|
||
|
||
### DB100 (Continuous Monitoring — 32 Bytes)
|
||
- **Bytes 0-3**: Status semaphores. Bit 0 of Byte 0 is `DatiPronti` flag.
|
||
- **Bytes 4-31**: Variable REAL counters (4 bytes each, 7 counters total).
|
||
- Poll interval: configurable (default 1000ms).
|
||
- When `DatiPronti` (Byte0.Bit0) transitions from 0→1, trigger immediate DB101 read.
|
||
|
||
### DB101 (On-Event Read — 4000 Bytes)
|
||
- Contains 1000 REAL values (4 bytes × 1000 = 4000 bytes).
|
||
- Read only on DB100 trigger event.
|
||
- Each read produces a historical `PLCRecord` with unique ID, timestamp, and payload.
|
||
|
||
## Persistence Pipeline
|
||
|
||
Strategy pattern per target. Pipeline runs after every PLC read batch or record event.
|
||
|
||
1. **File System** (always enabled in production migration): Serializes `PLCStateSnapshot` to JSON files with rotation.
|
||
2. **Redis** (optional): Stores state as Redis Hash keyed by `plc:{areaId}`.
|
||
3. **MariaDB** (optional): Ef Core `Pomelo.EntityFrameworkCore.MySql` provider, table `PLCRecords`.
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
solution/
|
||
├── src/
|
||
│ ├── PlcVanguard.Core/
|
||
│ │ ├── Data/
|
||
│ │ │ ├── ConnParam.cs PLC connection parameters
|
||
│ │ │ ├── DataProxy.cs CSV config model
|
||
│ │ │ ├── DataConf.cs Data memory mapping
|
||
│ │ │ ├── MemAddress.cs DBxxx.DBDyyy parser
|
||
│ │ │ └── AppSettings.cs App settings model
|
||
│ │ ├── PLC/
|
||
│ │ │ ├── IPlcService.cs PLC communication interface
|
||
│ │ │ ├── S7PlcService.cs S7.net 0.2.0 driver
|
||
│ │ │ ├── IPlcHostedService.cs Hosted service interface
|
||
│ │ │ ├── PlcHostedService.cs Background service (lifecycle, reconnect)
|
||
│ │ │ ├── ContinuousPoller.cs Continuous background poller
|
||
│ │ │ └── OnEventPoller.cs State-bit triggered poller
|
||
│ │ ├── State/
|
||
│ │ │ └── StateManager.cs In-memory state management
|
||
│ │ ├── Persistence/
|
||
│ │ │ ├── IPersistenceTarget.cs Persistence contract
|
||
│ │ │ ├── FilePersistence.cs JSON file persistence
|
||
│ │ │ ├── RedisPersistence.cs Redis hash persistence
|
||
│ │ │ └── MariaDbPersistence.cs EF Core MariaDB persistence
|
||
│ │ ├── Config/
|
||
│ │ │ └── AppConfig.cs Configuration helpers
|
||
│ │ ├── GlobalUsings.cs Global imports
|
||
│ │ └── PlcVanguard.Core.csproj
|
||
│ │
|
||
│ └── PlcVanguard.Web/
|
||
│ ├── Program.cs DI registration, app pipeline
|
||
│ ├── appsettings.json
|
||
│ ├── appsettings.Development.json
|
||
│ ├── PLCService.cs Web service (mock data, state queries)
|
||
│ ├── Components/
|
||
│ │ ├── App.razor
|
||
│ │ ├── Routes.razor
|
||
│ │ ├── _Imports.razor
|
||
│ │ ├── Pages/
|
||
│ │ │ ├── Home.razor
|
||
│ │ │ ├── Weather.razor
|
||
│ │ │ ├── Dashboard.razor Live monitoring, counters, trendline SVG
|
||
│ │ │ ├── Comparison.razor Multi-record comparison chart
|
||
│ │ │ ├── Counter.razor
|
||
│ │ │ └── Error.razor
|
||
│ │ └── Layout/
|
||
│ │ ├── MainLayout.razor
|
||
│ │ └── NavMenu.razor Dashboard | Counter | Comparison
|
||
│ ├── wwwroot/
|
||
│ │ ├── app.css
|
||
│ │ └── bootstrap/bootstrap.min.css
|
||
│ └── PlcVanguard.Web.csproj
|
||
│
|
||
├── PlcVanguard.slnx .NET 8 solution aggregator
|
||
└── ARCHITECTURE_AND_SPEC.md
|
||
```
|
||
|
||
### Dependency Graph
|
||
|
||
```
|
||
PlcVanguard.Web
|
||
└── PlcVanguard.Core
|
||
├── S7.Net (0.2.0)
|
||
├── Microsoft.Extensions.Hosting.Abstractions
|
||
├── Microsoft.Extensions.Options
|
||
├── Microsoft.Extensions.Logging.Abstractions
|
||
├── StackExchange.Redis
|
||
├── Pomelo.EntityFrameworkCore.MySql
|
||
└── Microsoft.EntityFrameworkCore
|
||
```
|
||
|
||
## Non-Functional Requirements
|
||
|
||
- **Framework**: C# 12, .NET 8 (long-term support).
|
||
- **Error Handling**: PLC read errors trigger reconnect with exponential backoff; app never crashes.
|
||
- **Logging**: `Microsoft.Extensions.Logging` structured logging to file and console.
|
||
- **Async**: All I/O (PLC, files, Redis, DB) is strictly `async/await`.
|
||
- **Dependency Injection**: Native `Microsoft.Extensions.DependencyInjection`.
|
||
- **Platform**: Cross-platform — Windows (IIS), Linux (systemd + Kestrel).
|
||
- **Migration**: Inherits proven PLC logic from MHT-Siemens (legacy .NET 4.0 WinForms); the new architecture decouples and extends it.
|
||
|
||
## Git Workflow
|
||
|
||
- **Task-driven development**: One commit per atomic task after successful compilation.
|
||
- **Commit convention**: `feat/fix/chore: brief description`
|
||
- **Autonomous completion**: Work through all phases without stopping for confirmations between tasks, unless a critical blocker occurs.
|