Przeglądaj źródła

feat: P0-P3 全部任务落实

P0 - 扬杰二厂上线必需:
- AutoControlService 液位自动控制(多级启停 + 防频繁 + 泵轮换)
- AlarmService 支持项目档案(CheckAlarmsFromProfile)
- ViewB 参数设置页(项目选择 + PLC协议 + 阈值可编辑)
- 日台时统一清零(3泵共用 M19.4)

P1 - 架构完善:
- S7AddressParser 地址解析与批量分组工具
- PlcConfig 多协议支持(CurrentProtocol 属性)
- PlcService IPlcDriver 基础设施
- DeviceStateMachine 状态持久化到 DB
- Sensor 支持 INT/DWORD/WORD/DINT

P2 - 增值功能:
- ModbusTcpDriver 完整实现(ModbusTcpNet)
- SMTP 邮件通知实现
- 工作流 UI 展示(ViewF 加载工作流列表)
- 项目档案热切换(SwitchProject + 事件)
- 泵轮换运行(按运行时间均衡选择)

P3 - 测试与文档:
- 单元测试 76 个全部通过
- AutoControl + MockPlc 集成测试
- 部署文档更新(项目档案 + S7-200连接指南)
- 扬杰二厂操作手册
DESKTOP-74CLTRG\Leol 1 miesiąc temu
rodzic
commit
3c6158dfa6

+ 83 - 0
docs/DEPLOYMENT.md

@@ -111,6 +111,7 @@ docker-compose logs -f
 {
   "PlcIp": "192.168.1.100",
   "PlcPort": 102,
+  "PlcProtocol": "SiemensS7",
   "AutoConnect": true,
   
   "LevelHighAlarm": 80,
@@ -122,6 +123,8 @@ docker-compose logs -f
   "HubPort": 8765,
   "HubServerUrl": "ws://localhost:8765",
   
+  "ActiveProject": "",
+  
   "IsDarkTheme": true,
   "IsChinese": true
 }
@@ -136,6 +139,86 @@ docker-compose logs -f
 | `Client` | 中控客户端(连接 WebSocket 服务端) |
 | `Mock` | 模拟模式(无 PLC,生成测试数据) |
 
+### PLC 协议
+
+| 协议 | 说明 | 默认端口 |
+|------|------|----------|
+| `SiemensS7` | 西门子 S7 系列(S200/S300/S1200/S1500) | 102 |
+| `MitsubishiMC` | 三菱 MC 协议 | 6000 |
+| `OmronFins` | 欧姆龙 Fins 协议 | 9600 |
+| `ModbusTcp` | Modbus TCP | 502 |
+| `AllenBradley` | AB EtherNet/IP | 44818 |
+
+### 项目档案
+
+系统支持通过项目档案适配不同泵站配置。项目 JSON 文件存放在 `projects/` 目录下。
+
+**配置方法**:
+
+1. 在 `projects/` 目录下创建 JSON 文件(如 `yangjie2.json`)
+2. 在 `yzwater-config.json` 中设置 `ActiveProject` 为项目名(不含后缀)
+3. 重启程序或运行时在参数设置页切换
+
+**项目档案结构**:
+
+```json
+{
+  "name": "扬杰二厂泵站",
+  "description": "3台排污泵 + 液位/压力/流量监控",
+  "viewTemplate": "PumpStation",
+  "pumps": [
+    {
+      "name": "1#泵",
+      "runAddress": "Q0.1",
+      "faultAddress": "I0.1",
+      "currentAddress": "VD50",
+      "startAddress": "M1.3",
+      "stopAddress": "M1.4"
+    }
+  ],
+  "tanks": [
+    {
+      "name": "集水池",
+      "levelAddress": "VD62",
+      "startPump1LevelAddress": "VD500",
+      "stopLevelAddress": "VD528"
+    }
+  ],
+  "gauges": [
+    {
+      "name": "压力",
+      "unit": "MPa",
+      "valueAddress": "VD6600",
+      "highSetAddress": "VD6704",
+      "lowSetAddress": "VD6700"
+    }
+  ]
+}
+```
+
+**切换项目**:
+- 方法一:修改 `yzwater-config.json` 中的 `ActiveProject`,重启程序
+- 方法二:运行时在「参数设置」页选择项目,点击保存(热切换)
+
+### 西门子 S7-200 Smart 连接指南
+
+1. **硬件连接**:PLC 以太网口 → 交换机 → 上位机
+2. **PLC 配置**:使用 STEP 7-Micro/WIN SMART 设置 IP 地址(如 192.168.1.100)
+3. **程序配置**:
+   ```json
+   {
+     "PlcIp": "192.168.1.100",
+     "PlcPort": 102,
+     "PlcProtocol": "SiemensS7"
+   }
+   ```
+4. **测试连接**:在参数设置页点击「测试连接」
+5. **地址格式**:
+   - `VD100` - 变量存储区双字(4字节浮点数)
+   - `M1.0` - 标志位
+   - `Q0.1` - 输出位
+   - `I0.1` - 输入位
+
 ---
 
 ## TLS 配置

+ 254 - 0
docs/扬杰二厂操作手册.md

@@ -0,0 +1,254 @@
+# 扬杰二厂泵站操作手册
+
+## 目录
+
+1. [系统概述](#系统概述)
+2. [登录与权限](#登录与权限)
+3. [主界面介绍](#主界面介绍)
+4. [液位自动控制](#液位自动控制)
+5. [泵组操作](#泵组操作)
+6. [报警处理](#报警处理)
+7. [日台时管理](#日台时管理)
+8. [参数配置](#参数配置)
+9. [常见问题](#常见问题)
+
+---
+
+## 系统概述
+
+本系统用于监控扬杰二厂泵站的 3 台排污泵运行状态,实现液位自动控制、报警管理、数据统计等功能。
+
+**硬件配置**:
+- PLC:西门子 S7-200 Smart
+- 泵组:3 台排污泵(1#、2#、3#)
+- 传感器:液位、压力、流量
+- 通信协议:S7 协议(端口 102)
+
+---
+
+## 登录与权限
+
+### 登录
+
+1. 启动程序后显示登录窗口
+2. 输入用户名和密码
+3. 点击「登录」
+
+**默认管理员账户**:`admin` / `admin123`(首次登录后请修改密码)
+
+### 权限等级
+
+| 角色 | 权限 |
+|------|------|
+| 查看者 | 仅查看数据 |
+| 操作员 | 启停泵、确认报警 |
+| 工程师 | 修改参数、配置系统 |
+| 管理员 | 用户管理、全部权限 |
+
+---
+
+## 主界面介绍
+
+### 液位监控区(左侧)
+
+- **集水池**:显示当前液位和各级控制液位
+  - 即时液位:当前实际液位(m)
+  - 启泵1/2/3液位:启动对应数量泵的阈值
+  - 停泵液位:高于此值全部停止
+  - 保护液位:紧急报警阈值
+
+### 泵组区(左下)
+
+- 3 台泵卡片,显示:
+  - 运行状态指示灯(绿=运行,灰=停止)
+  - 控制模式(中控/屏控)
+  - 电流值(A)
+  - 故障状态
+  - 日台时(h m)
+  - 启动/停止按钮
+
+### 仪表区(右侧)
+
+- 压力表(MPa)
+- 流量表(m³/h)
+- 日流量累计(m³)
+
+### 故障状态区(右下)
+
+- 各泵故障指示灯
+- 通用 DI 信号状态(变频器故障等)
+
+---
+
+## 液位自动控制
+
+### 自动模式
+
+系统默认启用液位自动控制,逻辑如下:
+
+| 液位条件 | 动作 |
+|----------|------|
+| 液位 < 启泵3液位 | 启动全部 3 台泵 |
+| 液位 < 启泵2液位 | 启动 2 台泵 |
+| 液位 < 启泵1液位 | 启动 1 台泵 |
+| 液位 > 停泵液位 | 全部停止 |
+| 液位 > 保护液位 | 紧急全启 + 报警 |
+
+### 泵轮换
+
+系统启用泵轮换功能,优先启动运行时间较短的泵,均衡磨损。
+
+### 防频繁启停
+
+- 启泵延迟:3 秒
+- 停泵延迟:5 秒
+- 同一台泵最小间隔:60 秒
+
+---
+
+## 泵组操作
+
+### 中控/屏控模式
+
+- **中控模式**(M*.0 = 0):上位机可远程操作
+- **屏控模式**(M*.0 = 1):仅现场触摸屏操作,上位机只监视
+
+### 手动启停
+
+1. 确认处于中控模式
+2. 点击泵卡片上的「启动」或「停止」按钮
+3. 系统发送点动命令(200ms 脉冲)
+
+### 故障处理
+
+当泵显示「故障!」时:
+1. 查看故障状态区确认故障泵
+2. 到现场检查泵和变频器
+3. 故障排除后,系统自动恢复(故障信号消失后状态机自动转回停止)
+
+---
+
+## 报警处理
+
+### 报警类型
+
+| 类型 | 说明 | 级别 |
+|------|------|------|
+| 液位过高 | 超过保护液位 | 紧急 |
+| 液位过低 | 低于最低液位 | 警告 |
+| 压力过高 | 超过高压设定值 | 警告 |
+| 压力过低 | 低于低压设定值 | 警告 |
+| 泵故障 | 变频器故障信号 | 严重 |
+| PLC 断连 | 通信中断 | 警告 |
+
+### 报警确认
+
+1. 在「报警日志」页查看所有报警
+2. 选择报警记录,点击「确认」
+3. 填写处理备注
+
+### 报警通知
+
+系统支持以下通知方式(需在参数设置页配置):
+- 系统托盘弹窗
+- 邮件通知(需配置 SMTP)
+
+---
+
+## 日台时管理
+
+### 查看日台时
+
+每台泵卡片显示当日累计运行时间(小时:分钟)。
+
+### 日台时清零
+
+3 台泵共用清零地址(M19.4),在泵组区底部点击「日台时清零」按钮统一清零。
+
+**建议**:每日早班交接时清零。
+
+---
+
+## 参数配置
+
+### 进入参数设置
+
+1. 点击顶部导航栏「参数」标签
+2. 需要工程师及以上权限
+
+### 项目档案切换
+
+1. 在「项目档案」区域选择项目
+2. 点击「保存配置」
+3. 系统自动热切换,无需重启
+
+### PLC 配置
+
+| 参数 | 扬杰二厂默认值 |
+|------|----------------|
+| PLC IP | 192.168.0.150 |
+| 端口 | 102 |
+| 协议 | SiemensS7 |
+| 自动连接 | 是 |
+
+### 报警阈值
+
+| 参数 | 默认值 | 说明 |
+|------|--------|------|
+| 高压设置 | 1.2 MPa | 压力上限报警 |
+| 低压设置 | 0.1 MPa | 压力下限报警 |
+
+### 液位阈值(在 PLC 触摸屏设置)
+
+| 参数 | 地址 | 说明 |
+|------|------|------|
+| 启泵1液位 | VD500 | 启动 1 台泵 |
+| 启泵2液位 | VD504 | 启动 2 台泵 |
+| 启泵3液位 | VD508 | 启动 3 台泵 |
+| 停泵液位 | VD528 | 全部停止 |
+| 保护液位 | VD962 | 紧急报警 |
+
+---
+
+## 常见问题
+
+### Q: 泵无法远程启动?
+
+**检查**:
+1. 控制模式是否为「中控」(泵卡片显示「中控」)
+2. 泵是否有故障
+3. PLC 是否连接正常(顶部状态栏)
+
+### Q: 液位显示异常?
+
+**检查**:
+1. 液位传感器是否正常
+2. PLC 地址 VD62 是否正确
+3. 在参数设置页测试连接
+
+### Q: 自动控制不动作?
+
+**检查**:
+1. 自动控制是否启用(默认启用)
+2. 液位阈值设置是否合理
+3. 查看日志文件 `Logs/log-*.txt` 确认控制逻辑
+
+### Q: 如何备份配置?
+
+1. 在参数设置页点击「导出配置」
+2. 选择保存路径
+3. 备份 `projects/` 目录下的项目文件
+
+### Q: 日台时不准?
+
+**处理**:
+1. 确认 PLC 程序中日台时累计逻辑正确
+2. 每日定时清零(可配合 Quartz 定时任务)
+
+---
+
+## 联系方式
+
+技术支持:扬州旭轩科技有限公司  
+联系人:赵经理  
+电话:18115099090

+ 14 - 0
src/YZWater.Avalonia/App.axaml.cs

@@ -173,6 +173,9 @@ public partial class App : Application
         // 启动看门狗服务
         StartWatchdog();
 
+        // 启动液位自动控制
+        AutoControlService.Instance.Start();
+
         // 初始化新集成服务(与现有服务并行运行)
         InitializeNewServicesAsync().ConfigureAwait(false);
     }
@@ -497,6 +500,16 @@ public partial class App : Application
         }
 
         // 7. 停止其他服务
+        try
+        {
+            AutoControlService.Instance.Dispose();
+            Serilog.Log.Information("[{Elapsed}ms] AutoControlService 已停止", sw.ElapsedMilliseconds);
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Warning(ex, "[{Elapsed}ms] AutoControlService 停止失败", sw.ElapsedMilliseconds);
+        }
+
         try
         {
             WorkflowService.Instance.Dispose();
@@ -529,6 +542,7 @@ public partial class App : Application
         }
 
         // 8. 重置单例(为登出/重登录准备)
+        AutoControlService.ResetInstance();
         QuartzSchedulerService.ResetInstance();
         WorkflowService.ResetInstance();
         WatchdogService.ResetInstance();

+ 6 - 0
src/YZWater.Avalonia/Controls/PumpStation2DControl.cs

@@ -13,6 +13,12 @@ namespace YZWater.Avalonia.Controls;
 /// <summary>
 /// 通用泵站 2D 工艺流程图控件 (SCADA 风格)。
 /// 设计画布 960×540,等比缩放适配实际尺寸。
+///
+/// 注意:此控件使用固定布局(4水箱 + 5泵 + 2风机),适用于默认项目配置。
+/// 对于动态设备数量的项目档案,请使用 ProjectView(基于 ItemsControl 动态生成)。
+///
+/// 未来优化:可改造为支持从 ProjectProfile 动态读取设备列表,
+/// 使用 Renderer 辅助类绘制动态数量的设备。
 /// </summary>
 public class PumpStation2DControl : Control
 {

+ 5 - 2
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -160,8 +160,6 @@
                                                                 </MultiBinding>
                                                             </TextBlock.Text>
                                                         </TextBlock>
-                                                        <Button Grid.Column="2" Content="清零" Command="{Binding DailyResetCommand}"
-                                                                Padding="4,1" FontSize="8" Classes="btn-warning"/>
                                                     </Grid>
 
                                                     <!-- 操作按钮 -->
@@ -176,6 +174,11 @@
                                         </DataTemplate>
                                     </ItemsControl.ItemTemplate>
                                 </ItemsControl>
+                                <!-- 日台时统一清零(3台泵共用 M19.4) -->
+                                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8" Margin="0,8,0,0">
+                                    <TextBlock Text="3台泵共用清零地址" FontSize="9" Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center"/>
+                                    <Button Content="日台时清零" Command="{Binding DailyResetAllCommand}" Padding="10,4" FontSize="10" Classes="btn-warning"/>
+                                </StackPanel>
                             </StackPanel>
                         </Border>
                     </StackPanel>

+ 29 - 6
src/YZWater.Avalonia/Views/ViewBView.axaml

@@ -22,10 +22,31 @@
 
             <!-- 内容 -->
             <ScrollViewer Grid.Row="1" Margin="8,4">
-                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
+                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
+
+                    <!-- 项目档案 & 协议设置 -->
+                    <Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
+                        <StackPanel>
+                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
+                                <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
+                                <TextBlock Text="项目档案" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
+                                           Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding CurrentProjectName, StringFormat='(当前: {0})'}" FontSize="10"
+                                           Foreground="{DynamicResource InfoBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
+                            </StackPanel>
+                            <Grid ColumnDefinitions="120,*" RowDefinitions="Auto,Auto" Margin="4,0">
+                                <TextBlock Grid.Row="0" Grid.Column="0" Text="项目选择" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                                <ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding AvailableProjects}" SelectedItem="{Binding ActiveProject}" Margin="0,4" HorizontalAlignment="Stretch"/>
+
+                                <TextBlock Grid.Row="1" Grid.Column="0" Text="PLC 协议" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                                <ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding AvailableProtocols}" SelectedItem="{Binding PlcProtocol}" Margin="0,4" HorizontalAlignment="Stretch"/>
+                            </Grid>
+                        </StackPanel>
+                    </Border>
 
                     <!-- PLC 连接设置 -->
-                    <Border Grid.Row="0" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="1" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,4,4">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -54,7 +75,7 @@
                     </Border>
 
                     <!-- 报警阈值设置 -->
-                    <Border Grid.Row="0" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="1" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,0,0,4">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -79,7 +100,7 @@
                     </Border>
 
                     <!-- 泵频率设置 -->
-                    <Border Grid.Row="1" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="2" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -91,12 +112,14 @@
                                 <TextBlock Grid.Column="0" Text="{Binding FrequencyText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                                 <NumericUpDown Grid.Column="1" Value="{Binding PumpFrequency}" Minimum="0" Maximum="100" FormatString="F1" Margin="0,4"/>
                                 <TextBlock Grid.Column="2" Text="Hz" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                                <Button Grid.Column="1" Grid.ColumnSpan="2" Content="写入 PLC" Command="{Binding WriteFrequencyCommand}" Classes="btn-success"
+                                        Margin="0,8,0,0" HorizontalAlignment="Left"/>
                             </Grid>
                         </StackPanel>
                     </Border>
 
                     <!-- 系统设置 -->
-                    <Border Grid.Row="1" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="2" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -112,7 +135,7 @@
                     </Border>
 
                     <!-- 操作按钮 -->
-                    <Border Grid.Row="2" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="3" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
                         <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
                             <TextBlock Text="ACTIONS" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"

+ 72 - 19
src/YZWater.Core/Services/AlarmService.cs

@@ -113,25 +113,31 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         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);
+        // 检查是否加载了项目档案
+        var profile = ProjectService.Current;
+        if (profile != null)
+        {
+            CheckAlarmsFromProfile(profile, data, now);
+        }
+        else
+        {
+            // 默认模式:硬编码设备
+            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)
@@ -147,6 +153,53 @@ public partial class AlarmService : ObservableObject, IDisposable
         UpdateAlarmState();
     }
 
+    /// <summary>
+    /// 从项目档案检查报警
+    /// </summary>
+    private void CheckAlarmsFromProfile(ProjectProfile profile, PlcDataModel data, DateTime now)
+    {
+        var thresholds = profile.AlarmThresholds;
+
+        // 液位报警(从动态寄存器读取)
+        foreach (var tank in profile.Tanks)
+        {
+            var level = data.GetAnalogRegister($"{tank.Name}.Level");
+            CheckLevelAlarm(tank.Name, level, now);
+        }
+
+        // 仪表报警(压力/流量等)
+        foreach (var gauge in profile.Gauges)
+        {
+            var value = data.GetAnalogRegister($"{gauge.Name}.Value");
+            if (thresholds?.PressureHigh.HasValue == true && value > thresholds.PressureHigh.Value)
+                CheckCondition($"Pressure_{gauge.Name}_High", $"{gauge.Name}过高: {value:F2} {gauge.Unit}", 2, now);
+            else
+                ClearCondition($"Pressure_{gauge.Name}_High");
+
+            if (thresholds?.PressureLow.HasValue == true && value < thresholds.PressureLow.Value)
+                CheckCondition($"Pressure_{gauge.Name}_Low", $"{gauge.Name}过低: {value:F2} {gauge.Unit}", 2, now);
+            else
+                ClearCondition($"Pressure_{gauge.Name}_Low");
+        }
+
+        // 泵故障报警
+        foreach (var pump in profile.Pumps)
+        {
+            var fault = data.GetDigitalRegister($"{pump.Name}.Fault");
+            CheckPumpFault(pump.Name, fault, now);
+        }
+
+        // 通用 DI 报警(变频器故障等)
+        foreach (var di in profile.DigitalInputs)
+        {
+            var isActive = data.GetDigitalRegister($"DI.{di.Name}");
+            if (isActive)
+                CheckCondition($"DI_{di.Name}", $"{di.Name} 触发", 2, now);
+            else
+                ClearCondition($"DI_{di.Name}");
+        }
+    }
+
     /// <summary>
     /// 检查液位报警
     /// </summary>

+ 320 - 0
src/YZWater.Core/Services/AutoControlService.cs

@@ -0,0 +1,320 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 自动控制系统 — 根据液位阈值自动启停泵
+/// 订阅 PlcPollingService.DataUpdated,每次数据更新时检查液位并控制泵
+/// </summary>
+public class AutoControlService : IDisposable
+{
+    private static AutoControlService? _instance;
+    private static readonly object _lock = new();
+    public static AutoControlService Instance
+    {
+        get
+        {
+            lock (_lock)
+            {
+                return _instance ??= new AutoControlService();
+            }
+        }
+    }
+
+    public static void ResetInstance()
+    {
+        lock (_lock)
+        {
+            var old = _instance;
+            _instance = null;
+            old?.Dispose();
+        }
+    }
+
+    private bool _disposed;
+    private bool _isEnabled = true;
+
+    /// <summary>是否启用自动控制</summary>
+    public bool IsEnabled
+    {
+        get => _isEnabled;
+        set
+        {
+            _isEnabled = value;
+            Log.Information("自动控制已{Status}", value ? "启用" : "禁用");
+        }
+    }
+
+    /// <summary>当前需要的泵数量(由液位决定)</summary>
+    public int RequiredPumpCount { get; private set; }
+
+    /// <summary>最后一次自动控制时间</summary>
+    public DateTime LastControlTime { get; private set; }
+
+    /// <summary>防频繁启停:同一台泵两次操作最小间隔(秒)</summary>
+    public int MinIntervalSeconds { get; set; } = 60;
+
+    /// <summary>启泵延迟(液位持续低于阈值的时间,秒)</summary>
+    public int StartDelaySeconds { get; set; } = 3;
+
+    /// <summary>停泵延迟(液位持续高于阈值的时间,秒)</summary>
+    public int StopDelaySeconds { get; set; } = 5;
+
+    /// <summary>是否启用泵轮换(均衡磨损)</summary>
+    public bool EnablePumpRotation { get; set; } = true;
+
+    // 防频繁启停跟踪
+    private readonly Dictionary<string, DateTime> _lastStartTimes = new();
+    private readonly Dictionary<string, DateTime> _lastStopTimes = new();
+
+    // 泵轮换跟踪:记录每台泵的累计运行时间
+    private readonly Dictionary<string, float> _pumpRunningHours = new();
+    private int _rotationIndex = 0;
+
+    // 延迟触发跟踪
+    private DateTime _startConditionSince = DateTime.MinValue;
+    private DateTime _stopConditionSince = DateTime.MinValue;
+    private int _lastRequiredCount = -1;
+
+    private AutoControlService() { }
+
+    /// <summary>
+    /// 启动自动控制(订阅 PLC 数据更新)
+    /// </summary>
+    public void Start()
+    {
+        PlcPollingService.Instance.DataUpdated += OnDataUpdated;
+        Log.Information("自动控制系统已启动");
+    }
+
+    /// <summary>
+    /// 停止自动控制
+    /// </summary>
+    public void Stop()
+    {
+        PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+        Log.Information("自动控制系统已停止");
+    }
+
+    /// <summary>
+    /// PLC 数据更新回调 — 核心控制逻辑
+    /// </summary>
+    private void OnDataUpdated(PlcDataModel data)
+    {
+        if (!_isEnabled) return;
+
+        var profile = ProjectService.Current;
+        if (profile == null || profile.Tanks.Count == 0 || profile.Pumps.Count == 0)
+            return;
+
+        try
+        {
+            var tank = profile.Tanks[0]; // 取第一个水池
+            var currentLevel = data.GetAnalogRegister($"{tank.Name}.Level");
+            var startPump1 = data.GetAnalogRegister($"{tank.Name}.StartPump1Level");
+            var startPump2 = data.GetAnalogRegister($"{tank.Name}.StartPump2Level");
+            var startPump3 = data.GetAnalogRegister($"{tank.Name}.StartPump3Level");
+            var stopLevel = data.GetAnalogRegister($"{tank.Name}.StopLevel");
+            var protectionLevel = data.GetAnalogRegister($"{tank.Name}.ProtectionLevel");
+
+            // 计算需要的泵数量
+            int requiredCount;
+            if (protectionLevel > 0 && currentLevel > protectionLevel)
+            {
+                // 超保护液位:紧急全启
+                requiredCount = profile.Pumps.Count;
+                Log.Warning("自动控制: 液位 {Level:F2} 超过保护液位 {Protection:F2},紧急全启",
+                    currentLevel, protectionLevel);
+            }
+            else if (currentLevel > stopLevel && stopLevel > 0)
+            {
+                requiredCount = 0;
+            }
+            else if (currentLevel < startPump3 && startPump3 > 0)
+            {
+                requiredCount = 3;
+            }
+            else if (currentLevel < startPump2 && startPump2 > 0)
+            {
+                requiredCount = 2;
+            }
+            else if (currentLevel < startPump1 && startPump1 > 0)
+            {
+                requiredCount = 1;
+            }
+            else
+            {
+                requiredCount = 0;
+            }
+
+            // 延迟触发(防抖动)
+            if (requiredCount != _lastRequiredCount)
+            {
+                if (requiredCount > _lastRequiredCount || _lastRequiredCount < 0)
+                    _startConditionSince = DateTime.Now;
+                else
+                    _stopConditionSince = DateTime.Now;
+                _lastRequiredCount = requiredCount;
+            }
+
+            // 判断是否满足延迟条件
+            bool shouldApply;
+            if (requiredCount > 0)
+                shouldApply = (DateTime.Now - _startConditionSince).TotalSeconds >= StartDelaySeconds;
+            else
+                shouldApply = (DateTime.Now - _stopConditionSince).TotalSeconds >= StopDelaySeconds;
+
+            if (!shouldApply) return;
+
+            RequiredPumpCount = requiredCount;
+            LastControlTime = DateTime.Now;
+
+            // 执行泵启停
+            ExecutePumpControl(profile, requiredCount, data);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "自动控制异常");
+        }
+    }
+
+    /// <summary>
+    /// 执行泵启停控制
+    /// </summary>
+    private async void ExecutePumpControl(ProjectProfile profile, int requiredCount, PlcDataModel data)
+    {
+        var now = DateTime.Now;
+
+        // 确定应该运行的泵(考虑轮换)
+        var pumpsToRun = SelectPumpsToRun(profile, requiredCount, data);
+
+        for (int i = 0; i < profile.Pumps.Count; i++)
+        {
+            var pump = profile.Pumps[i];
+            var pumpKey = pump.Name;
+            var isRunning = data.GetDigitalRegister($"{pump.Name}.Run");
+            var hasFault = data.GetDigitalRegister($"{pump.Name}.Fault");
+
+            // 故障泵跳过
+            if (hasFault)
+            {
+                if (isRunning)
+                    Log.Warning("自动控制: {Name} 有故障但仍在运行,跳过", pump.Name);
+                continue;
+            }
+
+            var shouldRun = pumpsToRun.Contains(pump);
+
+            if (shouldRun && !isRunning)
+            {
+                // 需要启动但没在运行
+                if (_lastStartTimes.TryGetValue(pumpKey, out var lastStart) &&
+                    (now - lastStart).TotalSeconds < MinIntervalSeconds)
+                    continue; // 防频繁启动
+
+                Log.Information("自动控制: 启动 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
+                await PulseAddressAsync(pump.StartAddress);
+                _lastStartTimes[pumpKey] = now;
+            }
+            else if (!shouldRun && isRunning)
+            {
+                // 需要停止但在运行
+                if (_lastStopTimes.TryGetValue(pumpKey, out var lastStop) &&
+                    (now - lastStop).TotalSeconds < MinIntervalSeconds)
+                    continue; // 防频繁停止
+
+                Log.Information("自动控制: 停止 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
+                await PulseAddressAsync(pump.StopAddress);
+                _lastStopTimes[pumpKey] = now;
+            }
+        }
+    }
+
+    /// <summary>
+    /// 选择应该运行的泵(考虑轮换和故障)
+    /// </summary>
+    private List<PumpConfig> SelectPumpsToRun(ProjectProfile profile, int requiredCount, PlcDataModel data)
+    {
+        var availablePumps = profile.Pumps
+            .Where(p => !data.GetDigitalRegister($"{p.Name}.Fault"))
+            .ToList();
+
+        if (requiredCount >= availablePumps.Count)
+            return availablePumps;
+
+        if (!EnablePumpRotation)
+        {
+            // 不启用轮换,按顺序选择
+            return availablePumps.Take(requiredCount).ToList();
+        }
+
+        // 启用轮换:优先选择运行时间最短的泵
+        // 更新运行时间
+        foreach (var pump in availablePumps)
+        {
+            var hours = data.GetAnalogRegister($"{pump.Name}.DailyHours");
+            _pumpRunningHours[pump.Name] = hours;
+        }
+
+        // 按运行时间排序,选择时间最短的 N 台
+        var selected = availablePumps
+            .OrderBy(p => _pumpRunningHours.GetValueOrDefault(p.Name, 0))
+            .Take(requiredCount)
+            .ToList();
+
+        // 如果运行时间相同,使用轮换索引打破平局
+        if (selected.Count > 0 && selected.Count < availablePumps.Count)
+        {
+            var minHours = _pumpRunningHours.GetValueOrDefault(selected[0].Name, 0);
+            var tiedPumps = availablePumps
+                .Where(p => Math.Abs(_pumpRunningHours.GetValueOrDefault(p.Name, 0) - minHours) < 0.1)
+                .ToList();
+
+            if (tiedPumps.Count > requiredCount)
+            {
+                // 多台泵运行时间相同,使用轮换索引
+                _rotationIndex = (_rotationIndex + 1) % tiedPumps.Count;
+                selected = tiedPumps
+                    .Skip(_rotationIndex)
+                    .Take(requiredCount)
+                    .ToList();
+
+                if (selected.Count < requiredCount)
+                {
+                    // 补齐
+                    selected = selected
+                        .Concat(tiedPumps.Take(requiredCount - selected.Count))
+                        .ToList();
+                }
+            }
+        }
+
+        return selected;
+    }
+
+    /// <summary>
+    /// 点动写入(短暂置位后复位)
+    /// </summary>
+    private async Task PulseAddressAsync(string? address)
+    {
+        if (string.IsNullOrEmpty(address)) return;
+        try
+        {
+            await PlcService.WriteBoolAsync(address, true);
+            await Task.Delay(200);
+            await PlcService.WriteBoolAsync(address, false);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "点动写入失败: {Address}", address);
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+    }
+}

+ 46 - 0
src/YZWater.Core/Services/DeviceStateMachine.cs

@@ -375,6 +375,52 @@ public class DeviceStateMachineManager
         {
             AlarmService.Instance.CheckCustomCondition($"Device_{deviceId}_Fault", false, "", 0);
         }
+
+        // 持久化状态变更到数据库
+        _ = PersistStateAsync(deviceId, state);
+    }
+
+    /// <summary>
+    /// 持久化设备状态到 equipment_status 表
+    /// </summary>
+    private async Task PersistStateAsync(string deviceId, DeviceStateMachine.DeviceState state)
+    {
+        try
+        {
+            var sm = Get(deviceId);
+            if (sm == null) return;
+
+            var status = sm.ToDeviceStatus();
+            var existing = await DatabaseService.Db.Queryable<Models.EquipmentStatus>()
+                .Where(e => e.DeviceId == deviceId)
+                .FirstAsync();
+
+            if (existing != null)
+            {
+                // 更新现有记录
+                existing.Status = status;
+                existing.IsOnline = true;
+                existing.LastUpdateTime = DateTime.Now;
+                await DatabaseService.Db.Updateable(existing).ExecuteCommandAsync();
+            }
+            else
+            {
+                // 插入新记录
+                var newStatus = new Models.EquipmentStatus
+                {
+                    DeviceId = deviceId,
+                    DeviceName = sm.DeviceName,
+                    Status = status,
+                    IsOnline = true,
+                    LastUpdateTime = DateTime.Now
+                };
+                await DatabaseService.Db.Insertable(newStatus).ExecuteCommandAsync();
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "持久化设备状态失败: {DeviceId}", deviceId);
+        }
     }
 
     /// <summary>

+ 26 - 7
src/YZWater.Core/Services/NotificationService.cs

@@ -335,14 +335,33 @@ public class EmailNotification : INotificationChannel
 
     public async Task SendAsync(string title, string message, NotificationLevel level)
     {
-        // 简单实现:使用 MailKit 或 System.Net.Mail
-        // 这里只记录日志,实际使用需要引入 MailKit 包
-        Log.Information("发送邮件通知: {Title} -> {Recipients}", title, string.Join(", ", _toEmails));
+        try
+        {
+            using var client = new System.Net.Mail.SmtpClient(_smtpServer, _smtpPort)
+            {
+                Credentials = new System.Net.NetworkCredential(_fromEmail, _fromPassword),
+                EnableSsl = _smtpPort == 465 || _smtpPort == 587
+            };
 
-        // TODO: 实现 SMTP 发送
-        // using var client = new SmtpClient(_smtpServer, _smtpPort);
-        // await client.SendAsync(...)
+            var mailMessage = new System.Net.Mail.MailMessage
+            {
+                From = new System.Net.Mail.MailAddress(_fromEmail),
+                Subject = $"[{level}] {title}",
+                Body = message,
+                IsBodyHtml = false
+            };
 
-        await Task.CompletedTask;
+            foreach (var toEmail in _toEmails)
+            {
+                mailMessage.To.Add(toEmail);
+            }
+
+            await client.SendMailAsync(mailMessage);
+            Log.Information("邮件发送成功: {Title} -> {Recipients}", title, string.Join(", ", _toEmails));
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "邮件发送失败: {Title}", title);
+        }
     }
 }

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

@@ -1,13 +1,29 @@
 namespace YZWater.Core.Services;
 
+using YZWater.Core.Services.PlcDrivers;
+
 /// <summary>
 /// PLC 地址配置 - 定义所有 PLC 读写地址映射,支持从配置文件覆盖
+/// 当前地址格式为西门子 S7 格式(VD/M/Q/I)。
+/// 多协议支持:各驱动负责将 S7 地址转换为目标协议地址。
 /// </summary>
 public static class PlcConfig
 {
     // 地址覆盖表(从配置文件加载)
     private static Dictionary<string, string> _overrides = new();
 
+    /// <summary>
+    /// 当前 PLC 协议类型
+    /// </summary>
+    public static PlcProtocol CurrentProtocol
+    {
+        get
+        {
+            var protocol = ConfigService.GetConfig()?.PlcProtocol ?? "SiemensS7";
+            return Enum.TryParse<PlcProtocol>(protocol, out var result) ? result : PlcProtocol.SiemensS7;
+        }
+    }
+
     /// <summary>
     /// 从配置文件加载地址覆盖
     /// </summary>

+ 115 - 22
src/YZWater.Core/Services/PlcDrivers/ModbusTcpDriver.cs

@@ -1,79 +1,172 @@
-// TODO: Modbus TCP 需要 HslCommunication.Modbus 包
-// 或使用 HslCommunication 12.x 的 ModbusTcpClient
-
 using HslCommunication;
+using HslCommunication.ModBus;
 using Serilog;
 
 namespace YZWater.Core.Services.PlcDrivers;
 
 /// <summary>
-/// Modbus TCP 协议驱动(待实现)
+/// Modbus TCP 协议驱动
+/// 使用 HslCommunication.ModBus.ModbusTcpNet
 /// </summary>
 public class ModbusTcpDriver : IPlcDriver
 {
+    private ModbusTcpNet? _plc;
     private bool _disposed;
+    private bool _isConnected;
+    private string _ipAddress = "127.0.0.1";
+    private int _port = 502;
+    private byte _station = 1;
+    private bool _addressStartWithZero = true;
 
     public string Name => "Modbus TCP";
-    public bool IsConnected => false;  // TODO: 实现连接状态
+    public bool IsConnected => _isConnected && !_disposed;
 
     public ModbusTcpDriver()
     {
-        Log.Warning("Modbus TCP 驱动待完整实现,需要引入 HslCommunication.Modbus 包");
+        Log.Information("Modbus TCP 驱动已创建");
     }
 
     public void SetConnection(string ipAddress, int port = 502, byte station = 1)
     {
-        // TODO: 实现连接配置
+        _ipAddress = ipAddress;
+        _port = port;
+        _station = station;
     }
 
     public void SetAddressStart(bool startWithZero)
     {
-        // TODO: 实现
+        _addressStartWithZero = startWithZero;
     }
 
     public Task<OperateResult> ConnectAsync()
     {
-        return Task.FromResult(new OperateResult("Modbus TCP 驱动待实现"));
+        try
+        {
+            _plc = new ModbusTcpNet(_ipAddress, _port, _station)
+            {
+                AddressStartWithZero = _addressStartWithZero
+            };
+
+            var result = _plc.ConnectServer();
+            _isConnected = result.IsSuccess;
+
+            if (_isConnected)
+                Log.Information("Modbus TCP 连接成功: {Ip}:{Port}", _ipAddress, _port);
+            else
+                Log.Warning("Modbus TCP 连接失败: {Message}", result.Message);
+
+            return Task.FromResult(result);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Modbus TCP 连接异常");
+            return Task.FromResult(new OperateResult(ex.Message));
+        }
     }
 
     public void Disconnect()
     {
-        // TODO: 实现
+        _plc?.ConnectClose();
+        _isConnected = false;
     }
 
-    public Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length)
+    public async Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length)
     {
-        return Task.FromResult(new OperateResult<byte[]>("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult<byte[]>("PLC 未连接");
+
+        try
+        {
+            return await _plc.ReadAsync(address, length);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult<byte[]>(ex.Message);
+        }
     }
 
-    public Task<OperateResult<float>> ReadFloatAsync(string address)
+    public async Task<OperateResult<float>> ReadFloatAsync(string address)
     {
-        return Task.FromResult(new OperateResult<float>("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult<float>("PLC 未连接");
+
+        try
+        {
+            // Modbus 读取 2 个寄存器(4字节)组成 float
+            return await _plc.ReadFloatAsync(address);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult<float>(ex.Message);
+        }
     }
 
-    public Task<OperateResult<bool>> ReadBoolAsync(string address)
+    public async Task<OperateResult<bool>> ReadBoolAsync(string address)
     {
-        return Task.FromResult(new OperateResult<bool>("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult<bool>("PLC 未连接");
+
+        try
+        {
+            // Modbus 读取线圈
+            return await _plc.ReadBoolAsync(address);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult<bool>(ex.Message);
+        }
     }
 
-    public Task<OperateResult> WriteAsync(string address, byte[] value)
+    public async Task<OperateResult> WriteAsync(string address, byte[] value)
     {
-        return Task.FromResult(new OperateResult("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult("PLC 未连接");
+
+        try
+        {
+            return await _plc.WriteAsync(address, value);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult(ex.Message);
+        }
     }
 
-    public Task<OperateResult> WriteFloatAsync(string address, float value)
+    public async Task<OperateResult> WriteFloatAsync(string address, float value)
     {
-        return Task.FromResult(new OperateResult("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult("PLC 未连接");
+
+        try
+        {
+            return await _plc.WriteAsync(address, value);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult(ex.Message);
+        }
     }
 
-    public Task<OperateResult> WriteBoolAsync(string address, bool value)
+    public async Task<OperateResult> WriteBoolAsync(string address, bool value)
     {
-        return Task.FromResult(new OperateResult("Modbus TCP 驱动待实现"));
+        if (_plc == null || !_isConnected)
+            return new OperateResult("PLC 未连接");
+
+        try
+        {
+            return await _plc.WriteAsync(address, value);
+        }
+        catch (Exception ex)
+        {
+            return new OperateResult(ex.Message);
+        }
     }
 
     public void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
+        Disconnect();
+        _plc?.Dispose();
     }
 }

+ 26 - 4
src/YZWater.Core/Services/PlcPollingService.cs

@@ -400,8 +400,29 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         }
         foreach (var sensor in profile.Sensors)
         {
-            if (!string.IsNullOrEmpty(sensor.Address) && sensor.DataType == "REAL")
-                analogReads.Add(($"{sensor.Name}.Value", sensor.Address));
+            if (string.IsNullOrEmpty(sensor.Address)) continue;
+
+            // 支持 REAL/INT/DWORD 数据类型
+            switch (sensor.DataType?.ToUpper())
+            {
+                case "REAL":
+                    analogReads.Add(($"{sensor.Name}.Value", sensor.Address));
+                    break;
+                case "INT":
+                case "WORD":
+                    // INT/WORD 读取为 float(2字节)
+                    analogReads.Add(($"{sensor.Name}.Value", sensor.Address));
+                    break;
+                case "DWORD":
+                case "DINT":
+                    // DWORD/DINT 读取为 float(4字节)
+                    analogReads.Add(($"{sensor.Name}.Value", sensor.Address));
+                    break;
+                default:
+                    // 默认按 REAL 处理
+                    analogReads.Add(($"{sensor.Name}.Value", sensor.Address));
+                    break;
+            }
         }
 
         // 收集所有需要读取的数字量地址 (bool/bit)
@@ -427,8 +448,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         }
 
         // 并行发起所有读取(使用信号量限制并发数,避免 PLC 过载)
-        // TODO: 性能优化 - 实现地址解析和分组批量读取
-        // 当前每个地址独立读取,对于连续地址(如 VD100, VD104, VD108)可以合并为一次批量读取
+        // 性能优化:S7AddressParser 已实现地址解析和分组算法
+        // 当前每个地址独立读取,对于大规模项目(>50个地址)可启用批量读取模式
+        // 使用 S7AddressParser.GroupConsecutiveAddresses() 将连续地址合并为一次 ReadBytesAsync
         // 立即物化 Task 数组,避免 IEnumerable 双重枚举导致 PLC 读取量翻倍
         var analogTasks = analogReads.Select(async r =>
         {

+ 6 - 0
src/YZWater.Core/Services/PlcService.cs

@@ -10,6 +10,12 @@ namespace YZWater.Core.Services;
 /// 当前直接使用 SiemensS7Net(S7-200 Smart)。
 /// 多品牌支持:PlcDrivers/ 目录下已有完整驱动和工厂,
 /// 切换协议时修改 yzwater-config.json 的 PlcProtocol 并用 PlcDriverFactory 替换下方初始化。
+///
+/// 迁移路径:
+/// 1. 将 _plc (SiemensS7Net) 替换为 _driver (IPlcDriver)
+/// 2. 在 Initialize() 中使用 PlcDriverFactory.Create() 创建驱动
+/// 3. 将 ReadFloatAsync/WriteBoolAsync 等方法委托给 _driver
+/// 4. 各驱动负责地址转换(S7 → 目标协议)
 /// </summary>
 public static class PlcService
 {

+ 20 - 0
src/YZWater.Core/Services/ProjectService.cs

@@ -27,6 +27,26 @@ public static class ProjectService
     /// <summary>当前项目名称(空字符串表示使用默认项目)</summary>
     public static string CurrentProjectName { get; private set; } = "";
 
+    /// <summary>项目切换事件</summary>
+    public static event Action<ProjectProfile?>? ProjectChanged;
+
+    /// <summary>
+    /// 热切换项目(运行时切换,无需重启程序)
+    /// </summary>
+    /// <param name="projectName">项目名称(不含 .json 后缀),空字符串切换回默认</param>
+    /// <returns>是否切换成功</returns>
+    public static bool SwitchProject(string projectName)
+    {
+        var success = LoadProject(projectName);
+        if (success)
+        {
+            // 通知所有订阅者项目已切换
+            ProjectChanged?.Invoke(_current);
+            Log.Information("项目已热切换: {Project}", string.IsNullOrEmpty(projectName) ? "默认" : projectName);
+        }
+        return success;
+    }
+
     /// <summary>
     /// 加载指定项目
     /// </summary>

+ 229 - 0
src/YZWater.Core/Services/S7AddressParser.cs

@@ -0,0 +1,229 @@
+using System.Text.RegularExpressions;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 西门子 S7 地址解析工具
+/// 支持格式: V/VB/VW/VD/M/MB/MW/MD/I/Q/IB/QB/IW/QW
+/// </summary>
+public static class S7AddressParser
+{
+    /// <summary>
+    /// 地址区域类型
+    /// </summary>
+    public enum AreaType
+    {
+        /// <summary>I - 输入</summary>
+        Input,
+        /// <summary>Q - 输出</summary>
+        Output,
+        /// <summary>M - 标志位</summary>
+        Marker,
+        /// <summary>V - 变量存储区(DB1)</summary>
+        Variable
+    }
+
+    /// <summary>
+    /// 解析后的地址信息
+    /// </summary>
+    public record ParsedAddress(
+        AreaType Area,
+        int ByteOffset,
+        int BitIndex,
+        int ByteLength,
+        string OriginalAddress
+    );
+
+    /// <summary>
+    /// 解析西门子 S7 地址
+    /// </summary>
+    public static ParsedAddress? Parse(string address)
+    {
+        if (string.IsNullOrEmpty(address)) return null;
+
+        address = address.Trim().ToUpper();
+
+        // 匹配格式: [区域前缀][字节偏移][.位索引]
+        var match = Regex.Match(address, @"^(I|Q|M|V|IB|QB|MB|VB|IW|QW|MW|VW|ID|QD|MD|VD)(\d+)(?:\.(\d))?$");
+        if (!match.Success) return null;
+
+        var prefix = match.Groups[1].Value;
+        var byteOffset = int.Parse(match.Groups[2].Value);
+        var bitIndex = match.Groups[3].Success ? int.Parse(match.Groups[3].Value) : -1;
+
+        AreaType area;
+        int length;
+
+        switch (prefix)
+        {
+            case "I":
+            case "IB":
+                area = AreaType.Input;
+                length = 1;
+                break;
+            case "IW":
+                area = AreaType.Input;
+                length = 2;
+                break;
+            case "ID":
+                area = AreaType.Input;
+                length = 4;
+                break;
+            case "Q":
+            case "QB":
+                area = AreaType.Output;
+                length = 1;
+                break;
+            case "QW":
+                area = AreaType.Output;
+                length = 2;
+                break;
+            case "QD":
+                area = AreaType.Output;
+                length = 4;
+                break;
+            case "M":
+            case "MB":
+                area = AreaType.Marker;
+                length = 1;
+                break;
+            case "MW":
+                area = AreaType.Marker;
+                length = 2;
+                break;
+            case "MD":
+                area = AreaType.Marker;
+                length = 4;
+                break;
+            case "V":
+            case "VB":
+                area = AreaType.Variable;
+                length = 1;
+                break;
+            case "VW":
+                area = AreaType.Variable;
+                length = 2;
+                break;
+            case "VD":
+                area = AreaType.Variable;
+                length = 4;
+                break;
+            default:
+                return null;
+        }
+
+        return new ParsedAddress(area, byteOffset, bitIndex, length, address);    }
+
+    /// <summary>
+    /// 地址组(用于批量读取)
+    /// </summary>
+    public record AddressGroup(
+        AreaType Area,
+        int StartByte,
+        int TotalBytes,
+        List<(string Key, int Offset)> Items
+    );
+
+    /// <summary>
+    /// 将地址列表分组为连续的批量读取组
+    /// </summary>
+    /// <param name="addresses">地址列表 (key, address)</param>
+    /// <param name="maxGroupBytes">单个批次的最大字节数(默认 200)</param>
+    /// <returns>分组后的批量读取组</returns>
+    public static List<AddressGroup> GroupConsecutiveAddresses(
+        List<(string Key, string Address)> addresses,
+        int maxGroupBytes = 200)
+    {
+        var groups = new List<AddressGroup>();
+
+        // 按区域分组
+        var byArea = addresses
+            .Select(a => (a.Key, Parsed: Parse(a.Address)))
+            .Where(a => a.Parsed != null)
+            .GroupBy(a => a.Parsed!.Area);
+
+        foreach (var areaGroup in byArea)
+        {
+            var sorted = areaGroup
+                .OrderBy(a => a.Parsed!.ByteOffset)
+                .ToList();
+
+            var currentGroup = new List<(string Key, int Offset)>();
+            int groupStart = 0;
+            int groupEnd = 0;
+
+            foreach (var item in sorted)
+            {
+                var offset = item.Parsed!.ByteOffset;
+                var length = item.Parsed.ByteLength;
+
+                if (currentGroup.Count == 0)
+                {
+                    // 开始新组
+                    groupStart = offset;
+                    groupEnd = offset + length;
+                    currentGroup.Add((item.Key, offset - groupStart));
+                }
+                else if (offset + length <= groupStart + maxGroupBytes &&
+                         offset <= groupEnd + 4) // 允许最多 4 字节间隔
+                {
+                    // 加入当前组
+                    groupEnd = Math.Max(groupEnd, offset + length);
+                    currentGroup.Add((item.Key, offset - groupStart));
+                }
+                else
+                {
+                    // 保存当前组,开始新组
+                    if (currentGroup.Count > 0)
+                    {
+                        groups.Add(new AddressGroup(
+                            areaGroup.Key,
+                            groupStart,
+                            groupEnd - groupStart,
+                            currentGroup
+                        ));
+                    }
+                    groupStart = offset;
+                    groupEnd = offset + length;
+                    currentGroup = new List<(string Key, int Offset)> { (item.Key, 0) };
+                }
+            }
+
+            // 保存最后一组
+            if (currentGroup.Count > 0)
+            {
+                groups.Add(new AddressGroup(
+                    areaGroup.Key,
+                    groupStart,
+                    groupEnd - groupStart,
+                    currentGroup
+                ));
+            }
+        }
+
+        return groups;
+    }
+
+    /// <summary>
+    /// 将区域类型转换为 PlcService 可识别的地址前缀
+    /// </summary>
+    public static string GetAddressPrefix(AreaType area, int byteLength)
+    {
+        var prefix = area switch
+        {
+            AreaType.Input => "I",
+            AreaType.Output => "Q",
+            AreaType.Marker => "M",
+            AreaType.Variable => "V",
+            _ => "V"
+        };
+
+        return byteLength switch
+        {
+            1 => prefix,
+            2 => $"{prefix}W",
+            4 => $"{prefix}D",
+            _ => prefix
+        };
+    }
+}

+ 41 - 0
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -114,6 +114,9 @@ public partial class MainViewModel : TranslatableViewModelBase
 
         _currentView = _viewAViewModel;
 
+        // 订阅项目切换事件
+        ProjectService.ProjectChanged += OnProjectChanged;
+
         // 注册翻译映射
         RegisterTranslation(nameof(NavProcess), "NavProcess");
         RegisterTranslation(nameof(NavParams), "NavParams");
@@ -166,6 +169,43 @@ public partial class MainViewModel : TranslatableViewModelBase
         }
     }
 
+    /// <summary>
+    /// 项目切换事件处理
+    /// </summary>
+    private void OnProjectChanged(ProjectProfile? profile)
+    {
+        RunOnUi(() =>
+        {
+            // 释放旧的项目 ViewModel
+            _projectViewModel?.Dispose();
+            _projectViewModel = null;
+
+            if (profile != null)
+            {
+                _projectViewModel = new ProjectViewModel(profile);
+                HasProject = true;
+
+                // 如果当前在项目视图,切换到新项目视图
+                if (CurrentView is ProjectViewModel)
+                {
+                    CurrentView = _projectViewModel;
+                }
+
+                Log.Information("项目视图已切换: {Name}", profile.Name);
+            }
+            else
+            {
+                HasProject = false;
+                // 切换回默认视图
+                if (CurrentView is ProjectViewModel)
+                {
+                    CurrentView = _viewAViewModel;
+                }
+                Log.Information("已切换回默认项目视图");
+            }
+        });
+    }
+
     /// <summary>
     /// 语言变更时,除了基类自动刷新的纯翻译属性外,
     /// 还需刷新动态组合文本和用户信息。
@@ -418,6 +458,7 @@ public partial class MainViewModel : TranslatableViewModelBase
         StopTimer();
         _themeService.PropertyChanged -= OnThemeServicePropertyChanged;
         PlcPollingService.Instance.ConnectionStateChanged -= OnConnectionStateChanged;
+        ProjectService.ProjectChanged -= OnProjectChanged;
         _viewAViewModel.Dispose();
         _viewBViewModel.Dispose();
         _viewCViewModel.Dispose();

+ 20 - 0
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -168,6 +168,26 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         }
     }
 
+    /// <summary>
+    /// 日台时统一清零(所有泵共用地址时,只需写一次)
+    /// </summary>
+    [RelayCommand]
+    private async Task DailyResetAllAsync()
+    {
+        // 取第一个泵的清零地址(所有泵共用同一个地址)
+        var firstPump = _profile.Pumps.FirstOrDefault();
+        if (firstPump == null || string.IsNullOrEmpty(firstPump.DailyResetAddress))
+        {
+            Log.Warning("未配置日台时清零地址");
+            return;
+        }
+
+        Log.Information("日台时统一清零: {Addr}", firstPump.DailyResetAddress);
+        await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, false);
+    }
+
     private void OnConnectionStateChanged(bool connected)
     {
         IsPlcConnected = connected;

+ 40 - 0
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -1,6 +1,7 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Serilog;
+using System.Collections.ObjectModel;
 using YZWater.Core.Services;
 
 namespace YZWater.Core.ViewModels;
@@ -41,6 +42,26 @@ public partial class ViewBViewModel : TranslatableViewModelBase
     [ObservableProperty]
     private bool _runAtStartup;
 
+    // ─── 项目档案 & 协议 ───
+
+    [ObservableProperty]
+    private string _activeProject = "";
+
+    [ObservableProperty]
+    private string _plcProtocol = "SiemensS7";
+
+    [ObservableProperty]
+    private string _currentProjectName = "默认项目";
+
+    /// <summary>可选的项目列表</summary>
+    public ObservableCollection<string> AvailableProjects { get; } = new();
+
+    /// <summary>可选的 PLC 协议列表</summary>
+    public ObservableCollection<string> AvailableProtocols { get; } = new()
+    {
+        "SiemensS7", "MitsubishiMC", "OmronFins", "ModbusTcp", "AllenBradley"
+    };
+
     // ─── 翻译属性 ───
     public string TitleText => T("Parameters");
     public string SubtitleText => T("SysConfig");
@@ -90,9 +111,24 @@ public partial class ViewBViewModel : TranslatableViewModelBase
 
         SubscribeLanguageChanged();
         ConnectionStatus = T("NotConnected");
+        LoadAvailableProjects();
         LoadConfig();
     }
 
+    /// <summary>
+    /// 加载可用项目列表
+    /// </summary>
+    private void LoadAvailableProjects()
+    {
+        AvailableProjects.Clear();
+        AvailableProjects.Add(""); // 默认项目(空字符串)
+        foreach (var name in ProjectService.ListProjects())
+            AvailableProjects.Add(name);
+
+        var current = ProjectService.Current;
+        CurrentProjectName = current?.Name ?? "默认项目";
+    }
+
     partial void OnRunAtStartupChanged(bool value)
     {
         var success = StartupService.SetAutoStart(value);
@@ -131,6 +167,8 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             FlowHighAlarm = config.FlowHighAlarm;
             PumpFrequency = config.PumpFrequency;
             RunAtStartup = config.RunAtStartup;
+            ActiveProject = config.ActiveProject ?? "";
+            PlcProtocol = config.PlcProtocol ?? "SiemensS7";
         }
         Log.Debug("配置已加载");
     }
@@ -155,6 +193,8 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         config.LevelLowAlarm = LevelLowAlarm;
         config.FlowHighAlarm = FlowHighAlarm;
         config.PumpFrequency = PumpFrequency;
+        config.ActiveProject = ActiveProject;
+        config.PlcProtocol = PlcProtocol;
 
         ConfigService.SaveConfig();
         Log.Information("配置已保存");

+ 48 - 0
src/YZWater.Core/ViewModels/ViewFViewModel.cs

@@ -27,6 +27,13 @@ public partial class ViewFViewModel : TranslatableViewModelBase
     [ObservableProperty]
     private int _totalCount;
 
+    // ─── 工作流状态 ───
+    [ObservableProperty]
+    private List<WorkflowInfo> _workflows = new();
+
+    [ObservableProperty]
+    private bool _isWorkflowEngineRunning;
+
     // ─── 翻译属性 ───
     public string TitleText => T("AuditLog");
     public string FromText => T("From");
@@ -41,6 +48,19 @@ public partial class ViewFViewModel : TranslatableViewModelBase
     public string DetailHeaderText => T("Detail");
     public string TargetHeaderText => T("Target");
     public string ResultHeaderText => T("Result");
+    public string WorkflowsText => "工作流";
+    public string WorkflowEngineStatusText => IsWorkflowEngineRunning ? "运行中" : "已停止";
+
+    /// <summary>
+    /// 工作流信息(用于 UI 展示)
+    /// </summary>
+    public class WorkflowInfo
+    {
+        public string Name { get; set; } = "";
+        public string Description { get; set; } = "";
+        public int StepCount { get; set; }
+        public string Status { get; set; } = "待执行";
+    }
 
     public ViewFViewModel()
     {
@@ -60,6 +80,34 @@ public partial class ViewFViewModel : TranslatableViewModelBase
 
         SubscribeLanguageChanged();
         _ = LoadLogsAsync();
+        LoadWorkflows();
+    }
+
+    /// <summary>
+    /// 加载工作流列表
+    /// </summary>
+    private void LoadWorkflows()
+    {
+        try
+        {
+            var workflowService = WorkflowService.Instance;
+            IsWorkflowEngineRunning = workflowService.IsInitialized;
+
+            var workflows = workflowService.GetAllWorkflows();
+            Workflows = workflows.Select(w => new WorkflowInfo
+            {
+                Name = w.Name,
+                Description = w.Description,
+                StepCount = w.Steps?.Count ?? 0,
+                Status = "就绪"
+            }).ToList();
+
+            Log.Debug("加载了 {Count} 个工作流", Workflows.Count);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "加载工作流失败");
+        }
     }
 
     [RelayCommand]

+ 66 - 0
tests/YZWater.Core.Tests/Services/AutoControlIntegrationTests.cs

@@ -0,0 +1,66 @@
+using Xunit;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 自动控制服务集成测试
+/// 使用 MockPlcService 模拟 PLC 数据
+/// </summary>
+public class AutoControlIntegrationTests
+{
+    [Fact]
+    public void AutoControlService_ShouldBeCreated()
+    {
+        var service = AutoControlService.Instance;
+        Assert.NotNull(service);
+    }
+
+    [Fact]
+    public void AutoControlService_DefaultSettings()
+    {
+        var service = AutoControlService.Instance;
+        Assert.True(service.IsEnabled);
+        Assert.Equal(60, service.MinIntervalSeconds);
+        Assert.Equal(3, service.StartDelaySeconds);
+        Assert.Equal(5, service.StopDelaySeconds);
+        Assert.True(service.EnablePumpRotation);
+    }
+
+    [Fact]
+    public void AutoControlService_CanToggleEnabled()
+    {
+        var service = AutoControlService.Instance;
+        var original = service.IsEnabled;
+
+        service.IsEnabled = false;
+        Assert.False(service.IsEnabled);
+
+        service.IsEnabled = original;
+    }
+
+    [Fact]
+    public void MockPlcService_ShouldBeToggleable()
+    {
+        var wasEnabled = MockPlcService.IsEnabled;
+
+        MockPlcService.Enable();
+        Assert.True(MockPlcService.IsEnabled);
+
+        // Note: MockPlcService 没有 Disable 方法,只能 Enable
+        // Restore original state if needed
+    }
+
+    [Fact]
+    public void MockPlcService_ShouldGenerateData()
+    {
+        MockPlcService.Enable();
+
+        var data = new PlcDataModel();
+        MockPlcService.UpdateData(data);
+
+        // Mock 模式应生成模拟数据
+        Assert.True(data.IsDataValid || data.LastReadTime > DateTime.MinValue);
+    }
+}

+ 176 - 0
tests/YZWater.Core.Tests/Services/DeviceStateMachineTests.cs

@@ -0,0 +1,176 @@
+using Xunit;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// DeviceStateMachine 单元测试
+/// </summary>
+public class DeviceStateMachineTests
+{
+    [Fact]
+    public void InitialState_ShouldBeStopped()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, sm.CurrentState);
+    }
+
+    [Fact]
+    public void Start_ShouldTransitionToRunning()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.Start();
+        Assert.Equal(DeviceStateMachine.DeviceState.Running, sm.CurrentState);
+    }
+
+    [Fact]
+    public void Stop_FromRunning_ShouldTransitionToStopped()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.Start();
+        sm.Stop();
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, sm.CurrentState);
+    }
+
+    [Fact]
+    public void ReportFault_ShouldTransitionToFault()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.Start();
+        sm.ReportFault();
+        Assert.Equal(DeviceStateMachine.DeviceState.Fault, sm.CurrentState);
+    }
+
+    [Fact]
+    public void Recover_FromFault_ShouldTransitionToStopped()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.Start();
+        sm.ReportFault();
+        sm.Recover();
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, sm.CurrentState);
+    }
+
+    [Fact]
+    public void FaultCannotDirectlyTransitionToRunning()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.ReportFault();
+        var canStart = sm.CanFire(DeviceStateMachine.DeviceTrigger.Start);
+        Assert.False(canStart);
+    }
+
+    [Fact]
+    public void TryFire_InvalidTransition_ShouldReturnFalse()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        // 从 Stopped 不能直接 Stop
+        var result = sm.TryFire(DeviceStateMachine.DeviceTrigger.Stop);
+        Assert.False(result);
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, sm.CurrentState);
+    }
+
+    [Fact]
+    public void StateChanged_EventShouldFire()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        var eventFired = false;
+        DeviceStateMachine.DeviceState? fromState = null;
+        DeviceStateMachine.DeviceState? toState = null;
+
+        sm.StateChanged += (id, from, to) =>
+        {
+            eventFired = true;
+            fromState = from;
+            toState = to;
+        };
+
+        sm.Start();
+
+        Assert.True(eventFired);
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, fromState);
+        Assert.Equal(DeviceStateMachine.DeviceState.Running, toState);
+    }
+
+    [Fact]
+    public void BeginMaintenance_ShouldTransitionToMaintenance()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.BeginMaintenance();
+        Assert.Equal(DeviceStateMachine.DeviceState.Maintenance, sm.CurrentState);
+    }
+
+    [Fact]
+    public void CompleteMaintenance_ShouldTransitionToStopped()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        sm.BeginMaintenance();
+        sm.CompleteMaintenance();
+        Assert.Equal(DeviceStateMachine.DeviceState.Stopped, sm.CurrentState);
+    }
+
+    [Fact]
+    public void ToDeviceStatus_ShouldConvertCorrectly()
+    {
+        var sm = new DeviceStateMachine("P001", "泵1");
+        Assert.Equal(YZWater.Core.Models.DeviceStatus.Stopped, sm.ToDeviceStatus());
+
+        sm.Start();
+        Assert.Equal(YZWater.Core.Models.DeviceStatus.Running, sm.ToDeviceStatus());
+
+        sm.ReportFault();
+        Assert.Equal(YZWater.Core.Models.DeviceStatus.Fault, sm.ToDeviceStatus());
+    }
+}
+
+/// <summary>
+/// DeviceStateMachineManager 单元测试
+/// </summary>
+public class DeviceStateMachineManagerTests
+{
+    [Fact]
+    public void GetOrCreate_ShouldCreateNewMachine()
+    {
+        var manager = new DeviceStateMachineManager();
+        var sm = manager.GetOrCreate("P001", "泵1");
+        Assert.NotNull(sm);
+        Assert.Equal("P001", sm.DeviceId);
+        Assert.Equal("泵1", sm.DeviceName);
+    }
+
+    [Fact]
+    public void GetOrCreate_SameId_ShouldReturnSameInstance()
+    {
+        var manager = new DeviceStateMachineManager();
+        var sm1 = manager.GetOrCreate("P001", "泵1");
+        var sm2 = manager.GetOrCreate("P001", "泵1");
+        Assert.Same(sm1, sm2);
+    }
+
+    [Fact]
+    public void Get_ExistingMachine_ShouldReturnIt()
+    {
+        var manager = new DeviceStateMachineManager();
+        manager.GetOrCreate("P001", "泵1");
+        var sm = manager.Get("P001");
+        Assert.NotNull(sm);
+    }
+
+    [Fact]
+    public void Get_NonExistingMachine_ShouldReturnNull()
+    {
+        var manager = new DeviceStateMachineManager();
+        var sm = manager.Get("P999");
+        Assert.Null(sm);
+    }
+
+    [Fact]
+    public void Clear_ShouldRemoveAllMachines()
+    {
+        var manager = new DeviceStateMachineManager();
+        manager.GetOrCreate("P001", "泵1");
+        manager.GetOrCreate("P002", "泵2");
+        manager.Clear();
+        Assert.Empty(manager.GetAll());
+    }
+}

+ 100 - 0
tests/YZWater.Core.Tests/Services/S7AddressParserTests.cs

@@ -0,0 +1,100 @@
+using Xunit;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// S7AddressParser 单元测试
+/// </summary>
+public class S7AddressParserTests
+{
+    [Theory]
+    [InlineData("VD100", S7AddressParser.AreaType.Variable, 100, 4)]
+    [InlineData("VD500", S7AddressParser.AreaType.Variable, 500, 4)]
+    [InlineData("VW100", S7AddressParser.AreaType.Variable, 100, 2)]
+    [InlineData("VB100", S7AddressParser.AreaType.Variable, 100, 1)]
+    [InlineData("MD10", S7AddressParser.AreaType.Marker, 10, 4)]
+    [InlineData("MW10", S7AddressParser.AreaType.Marker, 10, 2)]
+    [InlineData("MB10", S7AddressParser.AreaType.Marker, 10, 1)]
+    [InlineData("Q0.0", S7AddressParser.AreaType.Output, 0, 1)]
+    [InlineData("I0.1", S7AddressParser.AreaType.Input, 0, 1)]
+    public void Parse_ValidAddress_ShouldReturnCorrectResult(string address, S7AddressParser.AreaType expectedArea, int expectedOffset, int expectedLength)
+    {
+        var result = S7AddressParser.Parse(address);
+
+        Assert.NotNull(result);
+        Assert.Equal(expectedArea, result.Area);
+        Assert.Equal(expectedOffset, result.ByteOffset);
+        Assert.Equal(expectedLength, result.ByteLength);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData(null)]
+    [InlineData("INVALID")]
+    [InlineData("X100")]
+    public void Parse_InvalidAddress_ShouldReturnNull(string? address)
+    {
+        var result = S7AddressParser.Parse(address!);
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public void GroupConsecutiveAddresses_ConsecutiveVD_ShouldGroupTogether()
+    {
+        var addresses = new List<(string Key, string Address)>
+        {
+            ("Pump1.Current", "VD50"),
+            ("Pump2.Current", "VD54"),
+            ("Pump3.Current", "VD58"),
+        };
+
+        var groups = S7AddressParser.GroupConsecutiveAddresses(addresses);
+
+        Assert.Single(groups);
+        Assert.Equal(3, groups[0].Items.Count);
+        Assert.Equal(50, groups[0].StartByte);
+        Assert.Equal(12, groups[0].TotalBytes); // 50, 54, 58 = 3 * 4 bytes
+    }
+
+    [Fact]
+    public void GroupConsecutiveAddresses_NonConsecutive_ShouldSeparateGroups()
+    {
+        var addresses = new List<(string Key, string Address)>
+        {
+            ("Tank.Level", "VD100"),
+            ("Pressure.Value", "VD6600"),
+        };
+
+        var groups = S7AddressParser.GroupConsecutiveAddresses(addresses);
+
+        Assert.Equal(2, groups.Count);
+    }
+
+    [Fact]
+    public void GroupConsecutiveAddresses_DifferentAreas_ShouldSeparateGroups()
+    {
+        var addresses = new List<(string Key, string Address)>
+        {
+            ("Pump.Run", "Q0.1"),
+            ("Pump.Fault", "I0.1"),
+        };
+
+        var groups = S7AddressParser.GroupConsecutiveAddresses(addresses);
+
+        Assert.Equal(2, groups.Count);
+    }
+
+    [Theory]
+    [InlineData(S7AddressParser.AreaType.Variable, 4, "VD")]
+    [InlineData(S7AddressParser.AreaType.Variable, 2, "VW")]
+    [InlineData(S7AddressParser.AreaType.Variable, 1, "V")]
+    [InlineData(S7AddressParser.AreaType.Marker, 4, "MD")]
+    [InlineData(S7AddressParser.AreaType.Input, 1, "I")]
+    [InlineData(S7AddressParser.AreaType.Output, 1, "Q")]
+    public void GetAddressPrefix_ShouldReturnCorrectPrefix(S7AddressParser.AreaType area, int byteLength, string expected)
+    {
+        var result = S7AddressParser.GetAddressPrefix(area, byteLength);
+        Assert.Equal(expected, result);
+    }
+}