袩械褉械谐谢褟薪褍褌懈 写卸械褉械谢芯

闃舵1: 鍒涘缓PLC杞/鏁版嵁鏃ュ織/鎶ヨ鏈嶅姟 - 鏁版嵁灞傛墦閫

纾 鏇 6 写薪褨胁 褌芯屑褍
斜邪褌褜泻芯
泻芯屑褨褌
bde702057a

+ 21 - 0
src/YZWater.Avalonia/Views/ViewAView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewAView : UserControl
     public ViewAView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewBView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewBView : UserControl
     public ViewBView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewCView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewCView : UserControl
     public ViewCView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewDView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewDView : UserControl
     public ViewDView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewEView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewEView : UserControl
     public ViewEView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 147 - 0
src/YZWater.Core/Models/PlcDataModel.cs

@@ -0,0 +1,147 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// PLC 鏁版嵁妯″瀷 - 鍐呭瓨鏁版嵁蹇収
+/// </summary>
+public partial class PlcDataModel : ObservableObject
+{
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  姘寸娑蹭綅
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private float _tank1Level;
+    [ObservableProperty] private float _tank2Level;
+    [ObservableProperty] private float _tank3Level;
+    [ObservableProperty] private float _tank4Level;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  娴侀噺
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private float _inflowRate;
+    [ObservableProperty] private float _outflowRate;
+    [ObservableProperty] private float _flowDelta;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  娉电姸鎬 (0-4)
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private bool _pump1Running;
+    [ObservableProperty] private bool _pump2Running;
+    [ObservableProperty] private bool _pump3Running;
+    [ObservableProperty] private bool _pump4Running;
+    [ObservableProperty] private bool _pump5Running;
+
+    // 娉垫晠闅
+    [ObservableProperty] private bool _pump1Fault;
+    [ObservableProperty] private bool _pump2Fault;
+    [ObservableProperty] private bool _pump3Fault;
+    [ObservableProperty] private bool _pump4Fault;
+    [ObservableProperty] private bool _pump5Fault;
+
+    // 娉甸鐜
+    [ObservableProperty] private float _pump1Freq;
+    [ObservableProperty] private float _pump2Freq;
+    [ObservableProperty] private float _pump3Freq;
+    [ObservableProperty] private float _pump4Freq;
+    [ObservableProperty] private float _pump5Freq;
+
+    // 娉电數娴
+    [ObservableProperty] private float _pump1Current;
+    [ObservableProperty] private float _pump2Current;
+    [ObservableProperty] private float _pump3Current;
+    [ObservableProperty] private float _pump4Current;
+    [ObservableProperty] private float _pump5Current;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  椋庢満鐘舵
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private bool _fan1Running;
+    [ObservableProperty] private bool _fan2Running;
+
+    [ObservableProperty] private bool _fan1Fault;
+    [ObservableProperty] private bool _fan2Fault;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  闃闂ㄤ綅缃 (0-3, 0=100=鍏ㄥ叧, 100=鍏ㄥ紑)
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private int _valve1Position;
+    [ObservableProperty] private int _valve2Position;
+    [ObservableProperty] private int _valve3Position;
+    [ObservableProperty] private int _valve4Position;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  绯荤粺鐘舵
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private bool _isAutoMode;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  鏁版嵁璐ㄩ噺
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    [ObservableProperty] private DateTime _lastReadTime;
+    [ObservableProperty] private bool _isDataValid;
+    [ObservableProperty] private bool _isPlcConnected;
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  璁$畻灞炴
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    public int RunningPumpCount => (Pump1Running ? 1 : 0) + (Pump2Running ? 1 : 0) +
+                                   (Pump3Running ? 1 : 0) + (Pump4Running ? 1 : 0) +
+                                   (Pump5Running ? 1 : 0);
+
+    public int RunningFanCount => (Fan1Running ? 1 : 0) + (Fan2Running ? 1 : 0);
+
+    public bool HasAnyFault => Pump1Fault || Pump2Fault || Pump3Fault || Pump4Fault || Pump5Fault ||
+                               Fan1Fault || Fan2Fault;
+
+    /// <summary>
+    /// 鑾峰彇娉佃繍琛岀姸鎬
+    /// </summary>
+    public bool GetPumpRunning(int index) => index switch
+    {
+        0 => Pump1Running,
+        1 => Pump2Running,
+        2 => Pump3Running,
+        3 => Pump4Running,
+        4 => Pump5Running,
+        _ => false
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉垫晠闅滅姸鎬
+    /// </summary>
+    public bool GetPumpFault(int index) => index switch
+    {
+        0 => Pump1Fault,
+        1 => Pump2Fault,
+        2 => Pump3Fault,
+        3 => Pump4Fault,
+        4 => Pump5Fault,
+        _ => false
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉甸鐜
+    /// </summary>
+    public float GetPumpFreq(int index) => index switch
+    {
+        0 => Pump1Freq,
+        1 => Pump2Freq,
+        2 => Pump3Freq,
+        3 => Pump4Freq,
+        4 => Pump5Freq,
+        _ => 0
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉电數娴
+    /// </summary>
+    public float GetPumpCurrent(int index) => index switch
+    {
+        0 => Pump1Current,
+        1 => Pump2Current,
+        2 => Pump3Current,
+        3 => Pump4Current,
+        4 => Pump5Current,
+        _ => 0
+    };
+}

+ 361 - 0
src/YZWater.Core/Services/AlarmService.cs

@@ -0,0 +1,361 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 鎶ヨ鏈嶅姟 - 鎶ヨ妫娴嬨佹寔涔呭寲銆佺‘璁ゃ佸欢杩
+/// </summary>
+public partial class AlarmService : ObservableObject, IDisposable
+{
+    private readonly PlcPollingService _pollingService;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+
+    // 鈹鈹鈹 鎶ヨ閰嶇疆 鈹鈹鈹
+    public float LevelHighAlarm { get; set; } = 80f;
+    public float LevelLowAlarm { get; set; } = 20f;
+    public float FlowHighAlarm { get; set; } = 100f;
+    public float FlowLowAlarm { get; set; } = 5f;
+
+    /// <summary>
+    /// 鎶ヨ寤惰繜锛堢锛- 鎸佺画瓒呰繃姝ゆ椂闂存墠瑙﹀彂
+    /// </summary>
+    public int AlarmDelaySeconds { get; set; } = 3;
+
+    /// <summary>
+    /// 鎶ヨ姝诲尯锛堥槻姝㈤绻佽Е鍙戯級
+    /// </summary>
+    public float AlarmDeadband { get; set; } = 2f;
+
+    // 鈹鈹鈹 鐘舵 鈹鈹鈹
+    [ObservableProperty] private bool _hasActiveAlarm;
+    [ObservableProperty] private string _activeAlarmMessage = string.Empty;
+    [ObservableProperty] private int _activeAlarmCount;
+
+    // 鈹鈹鈹 浜嬩欢 鈹鈹鈹
+    public event Action<AlarmRecord>? AlarmRaised;
+    public event Action<AlarmRecord>? AlarmAcknowledged;
+    public event Action? AlarmStateChanged;
+
+    // 鈹鈹鈹 鎶ヨ璺熻釜 鈹鈹鈹
+    private readonly Dictionary<string, DateTime> _alarmStartTimes = new();
+    private readonly HashSet<string> _activeAlarms = new();
+
+    public AlarmService(PlcPollingService pollingService)
+    {
+        _pollingService = pollingService;
+        _pollingService.DataUpdated += OnDataUpdated;
+    }
+
+    /// <summary>
+    /// 鍚姩鎶ヨ鐩戞帶
+    /// </summary>
+    public void Start()
+    {
+        _cts = new CancellationTokenSource();
+        Log.Information("鎶ヨ鏈嶅姟宸插惎鍔");
+    }
+
+    /// <summary>
+    /// 鍋滄鎶ヨ鐩戞帶
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        Log.Information("鎶ヨ鏈嶅姟宸插仠姝");
+    }
+
+    /// <summary>
+    /// 鏁版嵁鏇存柊鍥炶皟
+    /// </summary>
+    private void OnDataUpdated(PlcDataModel data)
+    {
+        CheckAlarms(data);
+    }
+
+    /// <summary>
+    /// 妫鏌ユ墍鏈夋姤璀︽潯浠
+    /// </summary>
+    private void CheckAlarms(PlcDataModel data)
+    {
+        var now = DateTime.Now;
+
+        // 妫鏌ユ按绠辨恫浣
+        CheckLevelAlarm("Tank1", data.Tank1Level, now);
+        CheckLevelAlarm("Tank2", data.Tank2Level, now);
+        CheckLevelAlarm("Tank3", data.Tank3Level, now);
+        CheckLevelAlarm("Tank4", data.Tank4Level, now);
+
+        // 妫鏌ユ祦閲
+        CheckFlowAlarm("Inflow", data.InflowRate, now);
+
+        // 妫鏌ユ车鏁呴殰
+        CheckPumpFault("Pump1", data.Pump1Fault, now);
+        CheckPumpFault("Pump2", data.Pump2Fault, now);
+        CheckPumpFault("Pump3", data.Pump3Fault, now);
+        CheckPumpFault("Pump4", data.Pump4Fault, now);
+        CheckPumpFault("Pump5", data.Pump5Fault, now);
+
+        // 妫鏌ラ鏈烘晠闅
+        CheckFanFault("Fan1", data.Fan1Fault, now);
+        CheckFanFault("Fan2", data.Fan2Fault, now);
+
+        // 妫鏌 PLC 閫氫俊
+        if (!data.IsPlcConnected)
+        {
+            CheckCondition("PLC_Connection", "PLC閫氫俊涓㈠け", 1, now);
+        }
+        else
+        {
+            ClearCondition("PLC_Connection");
+        }
+
+        // 鏇存柊鐘舵
+        UpdateAlarmState();
+    }
+
+    /// <summary>
+    /// 妫鏌ユ恫浣嶆姤璀
+    /// </summary>
+    private void CheckLevelAlarm(string tankId, float level, DateTime now)
+    {
+        if (level > LevelHighAlarm + AlarmDeadband)
+        {
+            CheckCondition($"{tankId}_High", $"{tankId} 娑蹭綅杩囬珮 ({level:F1}%)", 2, now);
+        }
+        else if (level < LevelLowAlarm - AlarmDeadband)
+        {
+            CheckCondition($"{tankId}_Low", $"{tankId} 娑蹭綅杩囦綆 ({level:F1}%)", 2, now);
+        }
+        else
+        {
+            ClearCondition($"{tankId}_High");
+            ClearCondition($"{tankId}_Low");
+        }
+    }
+
+    /// <summary>
+    /// 妫鏌ユ祦閲忔姤璀
+    /// </summary>
+    private void CheckFlowAlarm(string flowId, float rate, DateTime now)
+    {
+        if (rate > FlowHighAlarm)
+        {
+            CheckCondition($"{flowId}_High", $"{flowId} 娴侀噺杩囬珮 ({rate:F1} m鲁/h)", 2, now);
+        }
+        else if (rate < FlowLowAlarm && rate > 0) // 0 琛ㄧず鏃犳暟鎹紝涓嶆姤璀
+        {
+            CheckCondition($"{flowId}_Low", $"{flowId} 娴侀噺杩囦綆 ({rate:F1} m鲁/h)", 2, now);
+        }
+        else
+        {
+            ClearCondition($"{flowId}_High");
+            ClearCondition($"{flowId}_Low");
+        }
+    }
+
+    /// <summary>
+    /// 妫鏌ユ车鏁呴殰
+    /// </summary>
+    private void CheckPumpFault(string pumpId, bool hasFault, DateTime now)
+    {
+        if (hasFault)
+        {
+            CheckCondition($"{pumpId}_Fault", $"{pumpId} 鏁呴殰", 3, now);
+        }
+        else
+        {
+            ClearCondition($"{pumpId}_Fault");
+        }
+    }
+
+    /// <summary>
+    /// 妫鏌ラ鏈烘晠闅
+    /// </summary>
+    private void CheckFanFault(string fanId, bool hasFault, DateTime now)
+    {
+        if (hasFault)
+        {
+            CheckCondition($"{fanId}_Fault", $"{fanId} 鏁呴殰", 3, now);
+        }
+        else
+        {
+            ClearCondition($"{fanId}_Fault");
+        }
+    }
+
+    /// <summary>
+    /// 妫鏌ユ姤璀︽潯浠讹紙甯﹀欢杩燂級
+    /// </summary>
+    private void CheckCondition(string conditionId, string message, int level, DateTime now)
+    {
+        if (_activeAlarms.Contains(conditionId))
+            return; // 宸茬粡鍦ㄦ姤璀︿腑
+
+        if (!_alarmStartTimes.ContainsKey(conditionId))
+        {
+            _alarmStartTimes[conditionId] = now;
+            return; // 璁板綍寮濮嬫椂闂
+        }
+
+        // 妫鏌ュ欢杩
+        if ((now - _alarmStartTimes[conditionId]).TotalSeconds >= AlarmDelaySeconds)
+        {
+            TriggerAlarm(conditionId, message, level);
+        }
+    }
+
+    /// <summary>
+    /// 娓呴櫎鎶ヨ鏉′欢
+    /// </summary>
+    private void ClearCondition(string conditionId)
+    {
+        _alarmStartTimes.Remove(conditionId);
+        _activeAlarms.Remove(conditionId);
+    }
+
+    /// <summary>
+    /// 瑙﹀彂鎶ヨ
+    /// </summary>
+    private void TriggerAlarm(string conditionId, string message, int level)
+    {
+        _activeAlarms.Add(conditionId);
+        _alarmStartTimes.Remove(conditionId);
+
+        var alarm = new AlarmRecord
+        {
+            AlarmTime = DateTime.Now,
+            AlarmType = conditionId.Split('_')[0],
+            AlarmMessage = message,
+            AlarmLevel = level,
+            IsConfirmed = false
+        };
+
+        // 鎸佷箙鍖栧埌鏁版嵁搴
+        _ = SaveAlarmAsync(alarm);
+
+        // 閫氱煡
+        AlarmRaised?.Invoke(alarm);
+        UpdateAlarmState();
+
+        Log.Warning("鎶ヨ瑙﹀彂: {Message} (绾у埆 {Level})", message, level);
+    }
+
+    /// <summary>
+    /// 淇濆瓨鎶ヨ鍒版暟鎹簱
+    /// </summary>
+    private async Task SaveAlarmAsync(AlarmRecord alarm)
+    {
+        try
+        {
+            await DatabaseService.Db.Insert(alarm).ExecuteAffrowsAsync();
+            Log.Debug("鎶ヨ璁板綍宸蹭繚瀛: {Id}", alarm.Id);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "淇濆瓨鎶ヨ璁板綍澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 纭鎶ヨ
+    /// </summary>
+    public async Task AcknowledgeAlarmAsync(int alarmId, string operatorName = "鎿嶄綔鍛")
+    {
+        try
+        {
+            await DatabaseService.Db.Update<AlarmRecord>()
+                .Set(r => r.IsConfirmed, true)
+                .Set(r => r.ConfirmedTime, DateTime.Now)
+                .Set(r => r.ConfirmedBy, operatorName)
+                .Where(r => r.Id == alarmId)
+                .ExecuteAffrowsAsync();
+
+            var alarm = await DatabaseService.Db.Select<AlarmRecord>()
+                .Where(r => r.Id == alarmId)
+                .FirstAsync();
+
+            if (alarm != null)
+            {
+                AlarmAcknowledged?.Invoke(alarm);
+                UpdateAlarmState();
+            }
+
+            Log.Information("鎶ヨ {Id} 宸茬‘璁", alarmId);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "纭鎶ヨ澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 鎵归噺纭鎵鏈夋湭纭鎶ヨ
+    /// </summary>
+    public async Task AcknowledgeAllAsync(string operatorName = "鎿嶄綔鍛")
+    {
+        try
+        {
+            await DatabaseService.Db.Update<AlarmRecord>()
+                .Set(r => r.IsConfirmed, true)
+                .Set(r => r.ConfirmedTime, DateTime.Now)
+                .Set(r => r.ConfirmedBy, operatorName)
+                .Where(r => !r.IsConfirmed)
+                .ExecuteAffrowsAsync();
+
+            UpdateAlarmState();
+            Log.Information("鎵鏈夋姤璀﹀凡纭");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鎵归噺纭鎶ヨ澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 鏇存柊鎶ヨ鐘舵
+    /// </summary>
+    private void UpdateAlarmState()
+    {
+        HasActiveAlarm = _activeAlarms.Count > 0;
+        ActiveAlarmCount = _activeAlarms.Count;
+        ActiveAlarmMessage = _activeAlarms.Count > 0
+            ? string.Join("; ", _activeAlarms.Take(3))
+            : string.Empty;
+
+        AlarmStateChanged?.Invoke();
+    }
+
+    /// <summary>
+    /// 浠庢暟鎹簱鍔犺浇鏈‘璁ゆ姤璀
+    /// </summary>
+    public async Task<List<AlarmRecord>> GetUnacknowledgedAlarmsAsync()
+    {
+        return await DatabaseService.Db.Select<AlarmRecord>()
+            .Where(r => !r.IsConfirmed)
+            .OrderByDescending(r => r.AlarmTime)
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 鑾峰彇鎶ヨ鍘嗗彶
+    /// </summary>
+    public async Task<List<AlarmRecord>> GetAlarmHistoryAsync(DateTime start, DateTime end)
+    {
+        return await DatabaseService.Db.Select<AlarmRecord>()
+            .Where(r => r.AlarmTime >= start && r.AlarmTime <= end)
+            .OrderByDescending(r => r.AlarmTime)
+            .ToListAsync();
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        _pollingService.DataUpdated -= OnDataUpdated;
+        Stop();
+        Log.Information("鎶ヨ鏈嶅姟宸查噴鏀");
+    }
+}

+ 218 - 0
src/YZWater.Core/Services/DataLoggingService.cs

@@ -0,0 +1,218 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 鏁版嵁鏃ュ織鏈嶅姟 - 灏 PLC 鏁版嵁鍐欏叆鏁版嵁搴
+/// </summary>
+public class DataLoggingService : IDisposable
+{
+    private readonly PlcPollingService _pollingService;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+
+    /// <summary>
+    /// 娴侀噺璁板綍鍐欏叆闂撮殧锛堢锛
+    /// </summary>
+    public int FlowLogIntervalSeconds { get; set; } = 60;
+
+    /// <summary>
+    /// 璁惧鐘舵佸啓鍏ラ棿闅旓紙绉掞級
+    /// </summary>
+    public int DeviceStatusLogIntervalSeconds { get; set; } = 300;
+
+    /// <summary>
+    /// 鏁版嵁淇濈暀澶╂暟
+    /// </summary>
+    public int DataRetentionDays { get; set; } = 30;
+
+    public DataLoggingService(PlcPollingService pollingService)
+    {
+        _pollingService = pollingService;
+    }
+
+    /// <summary>
+    /// 鍚姩鏁版嵁璁板綍
+    /// </summary>
+    public void Start()
+    {
+        _cts = new CancellationTokenSource();
+        Task.Run(() => LoggingLoopAsync(_cts.Token));
+        Log.Information("鏁版嵁鏃ュ織鏈嶅姟宸插惎鍔");
+    }
+
+    /// <summary>
+    /// 鍋滄鏁版嵁璁板綍
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        Log.Information("鏁版嵁鏃ュ織鏈嶅姟宸插仠姝");
+    }
+
+    /// <summary>
+    /// 璁板綍寰幆
+    /// </summary>
+    private async Task LoggingLoopAsync(CancellationToken ct)
+    {
+        var flowTimer = 0;
+        var deviceTimer = 0;
+        var cleanupTimer = 0;
+
+        while (!ct.IsCancellationRequested)
+        {
+            try
+            {
+                await Task.Delay(1000, ct);
+
+                flowTimer++;
+                deviceTimer++;
+                cleanupTimer++;
+
+                // 瀹氭湡璁板綍娴侀噺鏁版嵁
+                if (flowTimer >= FlowLogIntervalSeconds)
+                {
+                    flowTimer = 0;
+                    await LogFlowDataAsync();
+                }
+
+                // 瀹氭湡璁板綍璁惧鐘舵
+                if (deviceTimer >= DeviceStatusLogIntervalSeconds)
+                {
+                    deviceTimer = 0;
+                    await LogDeviceStatusAsync();
+                }
+
+                // 姣忓ぉ娓呯悊涓娆¤繃鏈熸暟鎹
+                if (cleanupTimer >= 86400)
+                {
+                    cleanupTimer = 0;
+                    await CleanupOldDataAsync();
+                }
+            }
+            catch (OperationCanceledException)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "鏁版嵁璁板綍寮傚父");
+                await Task.Delay(5000, ct);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 璁板綍娴侀噺鏁版嵁
+    /// </summary>
+    private async Task LogFlowDataAsync()
+    {
+        try
+        {
+            var data = _pollingService.Data;
+            var record = new FlowRecord
+            {
+                RecordTime = DateTime.Now,
+                InflowRate = data.InflowRate,
+                OutflowRate = data.OutflowRate,
+                TotalInflow = 0, // TODO: 闇瑕佺疮璁¤绠
+                TotalOutflow = 0
+            };
+
+            await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
+            Log.Debug("娴侀噺鏁版嵁宸茶褰: 杩涙按={Inflow:F1}, 鍑烘按={Outflow:F1}",
+                record.InflowRate, record.OutflowRate);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "璁板綍娴侀噺鏁版嵁澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 璁板綍璁惧鐘舵
+    /// </summary>
+    private async Task LogDeviceStatusAsync()
+    {
+        try
+        {
+            var data = _pollingService.Data;
+            var now = DateTime.Now;
+
+            // 璁板綍娉电姸鎬
+            for (int i = 0; i < 5; i++)
+            {
+                var status = new EquipmentStatus
+                {
+                    DeviceId = $"P{i + 1:D3}",
+                    DeviceName = $"娉祘i + 1}",
+                    Status = data.GetPumpRunning(i) ? DeviceStatus.Running : DeviceStatus.Stopped,
+                    IsOnline = !data.GetPumpFault(i),
+                    RunningHours = 0, // TODO: 闇瑕佺疮璁
+                    LastUpdateTime = now,
+                    Value = data.GetPumpFreq(i),
+                    Unit = "Hz"
+                };
+                // TODO: 鎸佷箙鍖栬澶囩姸鎬
+            }
+
+            Log.Debug("璁惧鐘舵佸凡璁板綍");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "璁板綍璁惧鐘舵佸け璐");
+        }
+    }
+
+    /// <summary>
+    /// 娓呯悊杩囨湡鏁版嵁
+    /// </summary>
+    private async Task CleanupOldDataAsync()
+    {
+        try
+        {
+            var cutoff = DateTime.Now.AddDays(-DataRetentionDays);
+
+            var flowDeleted = await DatabaseService.Db.Delete<FlowRecord>()
+                .Where(r => r.RecordTime < cutoff)
+                .ExecuteAffrowsAsync();
+
+            var alarmDeleted = await DatabaseService.Db.Delete<AlarmRecord>()
+                .Where(r => r.AlarmTime < cutoff && r.IsConfirmed)
+                .ExecuteAffrowsAsync();
+
+            Log.Information("鏁版嵁娓呯悊瀹屾垚: 鍒犻櫎 {Flow} 鏉℃祦閲忚褰, {Alarm} 鏉℃姤璀﹁褰",
+                flowDeleted, alarmDeleted);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "娓呯悊杩囨湡鏁版嵁澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 鎵嬪姩鍐欏叆涓鏉℃祦閲忚褰
+    /// </summary>
+    public async Task ManualLogFlowAsync(float inflow, float outflow)
+    {
+        var record = new FlowRecord
+        {
+            RecordTime = DateTime.Now,
+            InflowRate = inflow,
+            OutflowRate = outflow,
+            TotalInflow = 0,
+            TotalOutflow = 0,
+            Remark = "鎵嬪姩璁板綍"
+        };
+        await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+        Log.Information("鏁版嵁鏃ュ織鏈嶅姟宸查噴鏀");
+    }
+}

+ 170 - 0
src/YZWater.Core/Services/PlcConfig.cs

@@ -0,0 +1,170 @@
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// PLC 鍦板潃閰嶇疆 - 瀹氫箟鎵鏈 PLC 璇诲啓鍦板潃鏄犲皠
+/// </summary>
+public static class PlcConfig
+{
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  璇诲彇鍦板潃 (Read)
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+
+    // 鈹鈹鈹 姘寸娑蹭綅 (float) 鈹鈹鈹
+    public const string Tank1Level = "VD100";
+    public const string Tank2Level = "VD104";
+    public const string Tank3Level = "VD108";
+    public const string Tank4Level = "VD112";
+
+    // 鈹鈹鈹 娴侀噺 (float) 鈹鈹鈹
+    public const string InflowRate = "VD200";
+    public const string OutflowRate = "VD204";
+
+    // 鈹鈹鈹 娉电姸鎬 (bool) 鈹鈹鈹
+    public const string Pump1Run = "Q0.0";
+    public const string Pump2Run = "Q0.1";
+    public const string Pump3Run = "Q0.2";
+    public const string Pump4Run = "Q0.3";
+    public const string Pump5Run = "Q0.4";
+
+    // 鈹鈹鈹 娉垫晠闅 (bool) 鈹鈹鈹
+    public const string Pump1Fault = "I0.0";
+    public const string Pump2Fault = "I0.1";
+    public const string Pump3Fault = "I0.2";
+    public const string Pump4Fault = "I0.3";
+    public const string Pump5Fault = "I0.4";
+
+    // 鈹鈹鈹 娉甸鐜囧弽棣 (float) 鈹鈹鈹
+    public const string Pump1Freq = "VD300";
+    public const string Pump2Freq = "VD304";
+    public const string Pump3Freq = "VD308";
+    public const string Pump4Freq = "VD312";
+    public const string Pump5Freq = "VD316";
+
+    // 鈹鈹鈹 娉电數娴 (float) 鈹鈹鈹
+    public const string Pump1Current = "VD320";
+    public const string Pump2Current = "VD324";
+    public const string Pump3Current = "VD328";
+    public const string Pump4Current = "VD332";
+    public const string Pump5Current = "VD336";
+
+    // 鈹鈹鈹 椋庢満鐘舵 (bool) 鈹鈹鈹
+    public const string Fan1Run = "Q0.5";
+    public const string Fan2Run = "Q0.6";
+
+    // 鈹鈹鈹 椋庢満鏁呴殰 (bool) 鈹鈹鈹
+    public const string Fan1Fault = "I0.5";
+    public const string Fan2Fault = "I0.6";
+
+    // 鈹鈹鈹 闃闂ㄤ綅缃弽棣 (int) 鈹鈹鈹
+    public const string Valve1Position = "VD500";
+    public const string Valve2Position = "VD504";
+    public const string Valve3Position = "VD508";
+    public const string Valve4Position = "VD512";
+
+    // 鈹鈹鈹 绯荤粺鐘舵 鈹鈹鈹
+    public const string SystemMode = "M0.0"; // true=鑷姩, false=鎵嬪姩
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  鍐欏叆鍦板潃 (Write)
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+
+    // 鈹鈹鈹 娉靛惎鍋滄帶鍒 (bool) 鈹鈹鈹
+    public const string Pump1Start = "Q0.0";
+    public const string Pump2Start = "Q0.1";
+    public const string Pump3Start = "Q0.2";
+    public const string Pump4Start = "Q0.3";
+    public const string Pump5Start = "Q0.4";
+
+    // 鈹鈹鈹 椋庢満鍚仠鎺у埗 (bool) 鈹鈹鈹
+    public const string Fan1Start = "Q0.5";
+    public const string Fan2Start = "Q0.6";
+
+    // 鈹鈹鈹 闃闂ㄦ帶鍒 (bool) 鈹鈹鈹
+    public const string Valve1Control = "Q1.0";
+    public const string Valve2Control = "Q1.1";
+    public const string Valve3Control = "Q1.2";
+    public const string Valve4Control = "Q1.3";
+
+    // 鈹鈹鈹 娉甸鐜囪瀹 (float) 鈹鈹鈹
+    public const string PumpFreqSetpoint = "VD600";
+
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+    //  杈呭姪鏂规硶
+    // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
+
+    /// <summary>
+    /// 鑾峰彇娉佃繍琛屽湴鍧
+    /// </summary>
+    public static string GetPumpRunAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Run,
+        1 => Pump2Run,
+        2 => Pump3Run,
+        3 => Pump4Run,
+        4 => Pump5Run,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉垫晠闅滃湴鍧
+    /// </summary>
+    public static string GetPumpFaultAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Fault,
+        1 => Pump2Fault,
+        2 => Pump3Fault,
+        3 => Pump4Fault,
+        4 => Pump5Fault,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉甸鐜囧湴鍧
+    /// </summary>
+    public static string GetPumpFreqAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Freq,
+        1 => Pump2Freq,
+        2 => Pump3Freq,
+        3 => Pump4Freq,
+        4 => Pump5Freq,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 鑾峰彇娉电數娴佸湴鍧
+    /// </summary>
+    public static string GetPumpCurrentAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Current,
+        1 => Pump2Current,
+        2 => Pump3Current,
+        3 => Pump4Current,
+        4 => Pump5Current,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 鑾峰彇闃闂ㄦ帶鍒跺湴鍧
+    /// </summary>
+    public static string GetValveControlAddress(int valveIndex) => valveIndex switch
+    {
+        0 => Valve1Control,
+        1 => Valve2Control,
+        2 => Valve3Control,
+        3 => Valve4Control,
+        _ => throw new ArgumentOutOfRangeException(nameof(valveIndex))
+    };
+
+    /// <summary>
+    /// 鑾峰彇闃闂ㄤ綅缃弽棣堝湴鍧
+    /// </summary>
+    public static string GetValvePositionAddress(int valveIndex) => valveIndex switch
+    {
+        0 => Valve1Position,
+        1 => Valve2Position,
+        2 => Valve3Position,
+        3 => Valve4Position,
+        _ => throw new ArgumentOutOfRangeException(nameof(valveIndex))
+    };
+}

+ 271 - 0
src/YZWater.Core/Services/PlcPollingService.cs

@@ -0,0 +1,271 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// PLC 杞鏈嶅姟 - 璐熻矗瀹炴椂鏁版嵁閲囬泦銆佽嚜鍔ㄩ噸杩炪佹暟鎹垎鍙
+/// </summary>
+public partial class PlcPollingService : ObservableObject, IDisposable
+{
+    private static PlcPollingService? _instance;
+    public static PlcPollingService Instance => _instance ??= new PlcPollingService();
+
+    private readonly PlcDataModel _data = new();
+    private CancellationTokenSource? _cts;
+    private Task? _pollingTask;
+    private bool _disposed;
+
+    // 鈹鈹鈹 閰嶇疆 鈹鈹鈹
+    public int PollingIntervalMs { get; set; } = 1000;
+    public float DeadbandPercent { get; set; } = 0.5f;
+    public int MaxReconnectAttempts { get; set; } = 10;
+    public int ReconnectBaseDelayMs { get; set; } = 1000;
+
+    // 鈹鈹鈹 鏁版嵁鍙樻洿浜嬩欢 鈹鈹鈹
+    public event Action<PlcDataModel>? DataUpdated;
+    public event Action<bool>? ConnectionStateChanged;
+    public event Action<string>? ErrorOccurred;
+
+    /// <summary>
+    /// 褰撳墠鏁版嵁妯″瀷
+    /// </summary>
+    public PlcDataModel Data => _data;
+
+    private PlcPollingService() { }
+
+    /// <summary>
+    /// 鍚姩杞
+    /// </summary>
+    public void Start()
+    {
+        if (_pollingTask != null && !_pollingTask.IsCompleted)
+        {
+            Log.Warning("杞宸插湪杩愯涓");
+            return;
+        }
+
+        _cts = new CancellationTokenSource();
+        _pollingTask = Task.Run(() => PollingLoopAsync(_cts.Token));
+        Log.Information("PLC 杞鏈嶅姟宸插惎鍔");
+    }
+
+    /// <summary>
+    /// 鍋滄杞
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        _pollingTask?.Wait(TimeSpan.FromSeconds(5));
+        _cts?.Dispose();
+        _cts = null;
+        _pollingTask = null;
+        Log.Information("PLC 杞鏈嶅姟宸插仠姝");
+    }
+
+    /// <summary>
+    /// 杞涓诲惊鐜
+    /// </summary>
+    private async Task PollingLoopAsync(CancellationToken ct)
+    {
+        var reconnectAttempts = 0;
+
+        while (!ct.IsCancellationRequested)
+        {
+            try
+            {
+                // 纭繚杩炴帴
+                if (!PlcService.IsConnected)
+                {
+                    var connected = await TryConnectWithRetryAsync(reconnectAttempts, ct);
+                    if (!connected)
+                    {
+                        reconnectAttempts++;
+                        if (reconnectAttempts >= MaxReconnectAttempts)
+                        {
+                            ErrorOccurred?.Invoke($"PLC 杩炴帴澶辫触锛屽凡閲嶈瘯 {MaxReconnectAttempts} 娆");
+                            break;
+                        }
+                        await Task.Delay(ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5), ct);
+                        continue;
+                    }
+                    reconnectAttempts = 0;
+                }
+
+                // 璇诲彇鏁版嵁
+                await ReadAllDataAsync(ct);
+
+                // 鏁版嵁鏇存柊浜嬩欢
+                DataUpdated?.Invoke(_data);
+
+                // 绛夊緟涓嬩竴娆¤疆璇
+                await Task.Delay(PollingIntervalMs, ct);
+            }
+            catch (OperationCanceledException)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "杞寮傚父");
+                ErrorOccurred?.Invoke($"杞寮傚父: {ex.Message}");
+
+                // 杩炴帴鏂紑锛屽皾璇曢噸杩
+                if (!PlcService.IsConnected)
+                {
+                    _data.IsPlcConnected = false;
+                    ConnectionStateChanged?.Invoke(false);
+                }
+
+                await Task.Delay(PollingIntervalMs * 2, ct);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 灏濊瘯杩炴帴锛堝甫閲嶈瘯锛
+    /// </summary>
+    private async Task<bool> TryConnectWithRetryAsync(int attempt, CancellationToken ct)
+    {
+        for (int i = 0; i <= attempt && i < 3; i++)
+        {
+            if (ct.IsCancellationRequested) return false;
+
+            var connected = await PlcService.ConnectAsync();
+            if (connected)
+            {
+                _data.IsPlcConnected = true;
+                ConnectionStateChanged?.Invoke(true);
+                return true;
+            }
+
+            if (i < attempt)
+                await Task.Delay(ReconnectBaseDelayMs * (i + 1), ct);
+        }
+        return false;
+    }
+
+    /// <summary>
+    /// 鎵归噺璇诲彇鎵鏈 PLC 鏁版嵁
+    /// </summary>
+    private async Task ReadAllDataAsync(CancellationToken ct)
+    {
+        if (ct.IsCancellationRequested) return;
+
+        // 姘寸娑蹭綅
+        var tank1 = await PlcService.ReadFloatAsync(PlcConfig.Tank1Level);
+        var tank2 = await PlcService.ReadFloatAsync(PlcConfig.Tank2Level);
+        var tank3 = await PlcService.ReadFloatAsync(PlcConfig.Tank3Level);
+        var tank4 = await PlcService.ReadFloatAsync(PlcConfig.Tank4Level);
+
+        // 娴侀噺
+        var inflow = await PlcService.ReadFloatAsync(PlcConfig.InflowRate);
+        var outflow = await PlcService.ReadFloatAsync(PlcConfig.OutflowRate);
+
+        // 娉电姸鎬
+        var p1 = await PlcService.ReadBoolAsync(PlcConfig.Pump1Run);
+        var p2 = await PlcService.ReadBoolAsync(PlcConfig.Pump2Run);
+        var p3 = await PlcService.ReadBoolAsync(PlcConfig.Pump3Run);
+        var p4 = await PlcService.ReadBoolAsync(PlcConfig.Pump4Run);
+        var p5 = await PlcService.ReadBoolAsync(PlcConfig.Pump5Run);
+
+        // 娉垫晠闅
+        var pf1 = await PlcService.ReadBoolAsync(PlcConfig.Pump1Fault);
+        var pf2 = await PlcService.ReadBoolAsync(PlcConfig.Pump2Fault);
+        var pf3 = await PlcService.ReadBoolAsync(PlcConfig.Pump3Fault);
+        var pf4 = await PlcService.ReadBoolAsync(PlcConfig.Pump4Fault);
+        var pf5 = await PlcService.ReadBoolAsync(PlcConfig.Pump5Fault);
+
+        // 娉甸鐜
+        var f1 = await PlcService.ReadFloatAsync(PlcConfig.Pump1Freq);
+        var f2 = await PlcService.ReadFloatAsync(PlcConfig.Pump2Freq);
+        var f3 = await PlcService.ReadFloatAsync(PlcConfig.Pump3Freq);
+        var f4 = await PlcService.ReadFloatAsync(PlcConfig.Pump4Freq);
+        var f5 = await PlcService.ReadFloatAsync(PlcConfig.Pump5Freq);
+
+        // 椋庢満
+        var fan1 = await PlcService.ReadBoolAsync(PlcConfig.Fan1Run);
+        var fan2 = await PlcService.ReadBoolAsync(PlcConfig.Fan2Run);
+        var fanf1 = await PlcService.ReadBoolAsync(PlcConfig.Fan1Fault);
+        var fanf2 = await PlcService.ReadBoolAsync(PlcConfig.Fan2Fault);
+
+        // 绯荤粺妯″紡
+        var autoMode = await PlcService.ReadBoolAsync(PlcConfig.SystemMode);
+
+        // 搴旂敤姝诲尯杩囨护鍚庢洿鏂版暟鎹
+        ApplyWithDeadband(_data.Tank1Level, tank1, v => _data.Tank1Level = v);
+        ApplyWithDeadband(_data.Tank2Level, tank2, v => _data.Tank2Level = v);
+        ApplyWithDeadband(_data.Tank3Level, tank3, v => _data.Tank3Level = v);
+        ApplyWithDeadband(_data.Tank4Level, tank4, v => _data.Tank4Level = v);
+        ApplyWithDeadband(_data.InflowRate, inflow, v => _data.InflowRate = v);
+        ApplyWithDeadband(_data.OutflowRate, outflow, v => _data.OutflowRate = v);
+
+        _data.Pump1Running = p1;
+        _data.Pump2Running = p2;
+        _data.Pump3Running = p3;
+        _data.Pump4Running = p4;
+        _data.Pump5Running = p5;
+
+        _data.Pump1Fault = pf1;
+        _data.Pump2Fault = pf2;
+        _data.Pump3Fault = pf3;
+        _data.Pump4Fault = pf4;
+        _data.Pump5Fault = pf5;
+
+        _data.Pump1Freq = f1;
+        _data.Pump2Freq = f2;
+        _data.Pump3Freq = f3;
+        _data.Pump4Freq = f4;
+        _data.Pump5Freq = f5;
+
+        _data.Fan1Running = fan1;
+        _data.Fan2Running = fan2;
+        _data.Fan1Fault = fanf1;
+        _data.Fan2Fault = fanf2;
+
+        _data.IsAutoMode = autoMode;
+        _data.LastReadTime = DateTime.Now;
+        _data.IsDataValid = true;
+        _data.IsPlcConnected = PlcService.IsConnected;
+        _data.FlowDelta = _data.InflowRate - _data.OutflowRate;
+
+        // 瑙﹀彂璁$畻灞炴ф洿鏂
+        OnPropertyChanged(nameof(PlcDataModel.FlowDelta));
+    }
+
+    /// <summary>
+    /// 姝诲尯杩囨护锛氬彧鏈夊彉鍖栬秴杩囬槇鍊兼墠鏇存柊
+    /// </summary>
+    private void ApplyWithDeadband(float currentValue, float newValue, Action<float> setter)
+    {
+        if (Math.Abs(currentValue) < 0.001f && Math.Abs(newValue) < 0.001f)
+            return; // 閮芥槸闆跺硷紝璺宠繃
+
+        var threshold = Math.Abs(currentValue) * DeadbandPercent / 100.0f;
+        if (Math.Abs(newValue - currentValue) > threshold || currentValue == 0)
+        {
+            setter(newValue);
+        }
+    }
+
+    /// <summary>
+    /// 绔嬪嵆璇诲彇涓娆★紙鎵嬪姩鍒锋柊锛
+    /// </summary>
+    public async Task RefreshNowAsync()
+    {
+        if (PlcService.IsConnected)
+        {
+            await ReadAllDataAsync(CancellationToken.None);
+            DataUpdated?.Invoke(_data);
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+        Log.Information("PLC 杞鏈嶅姟宸查噴鏀");
+    }
+}