# 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` | ✅ | 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] 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(); public List 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(); public List RealValues { get; set; } = new(); public Dictionary Metadata { get; set; } = new(); } // Current state snapshot (one entry per configured area) public class PLCStateSnapshot { public DateTime Timestamp { get; set; } = DateTime.UtcNow; public Dictionary 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.