A:优化PLC数据管理
This commit is contained in:
1 parent
2f3eb935dd
commit
bc09280d4e
16 files changed
+592
-123
No files matched your search
@@ -7,6 +7,7 @@ using Plc.Models;
|
|||||||
using Plc.Siemens.Options;
|
using Plc.Siemens.Options;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using Sharp.Wpf.Abstractions;
|
||||||
|
|
||||||
namespace Client.ViewModels;
|
namespace Client.ViewModels;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public class PlcViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IPlcService _plcService;
|
private readonly IPlcService _plcService;
|
||||||
private readonly SiemensPlcOptions _siemensOptions;
|
private readonly SiemensPlcOptions _siemensOptions;
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
|
||||||
// 连接配置
|
// 连接配置
|
||||||
private PlcBrand _selectedBrand = PlcBrand.Siemens;
|
private PlcBrand _selectedBrand = PlcBrand.Siemens;
|
||||||
@@ -26,24 +28,29 @@ public class PlcViewModel : ViewModelBase
|
|||||||
private string _readValue = "-";
|
private string _readValue = "-";
|
||||||
private string _writeAddress = "DB1.DBW0";
|
private string _writeAddress = "DB1.DBW0";
|
||||||
private string _writeValue = "0";
|
private string _writeValue = "0";
|
||||||
|
|
||||||
// 监控
|
// 监控
|
||||||
private string _monitorAddress = "DB1.DBW0";
|
private string _monitorAddress = "DB1.DBW0";
|
||||||
|
|
||||||
public PlcViewModel(IPlcService plcService, SiemensPlcOptions siemensOptions)
|
public PlcViewModel(
|
||||||
|
IPlcService plcService,
|
||||||
|
SiemensPlcOptions siemensOptions,
|
||||||
|
IDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
_plcService = plcService;
|
_plcService = plcService;
|
||||||
_siemensOptions = siemensOptions;
|
_siemensOptions = siemensOptions;
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
|
||||||
// 初始化界面值为当前配置值
|
// 初始化界面值为当前配置值
|
||||||
_ipAddress = _siemensOptions.IpAddress;
|
_ipAddress = _siemensOptions.IpAddress;
|
||||||
_selectedCpuType = _siemensOptions.CpuType;
|
_selectedCpuType = _siemensOptions.CpuType;
|
||||||
|
|
||||||
ConnectCommand = new AsyncCommand(ConnectAsync);
|
ConnectCommand = new AsyncCommand(ConnectAsync);
|
||||||
DisconnectCommand = new AsyncCommand(DisconnectAsync);
|
DisconnectCommand = new AsyncCommand(DisconnectAsync);
|
||||||
ReadCommand = new AsyncCommand(ReadAsync);
|
ReadCommand = new AsyncCommand(ReadAsync);
|
||||||
WriteCommand = new AsyncCommand(WriteAsync);
|
WriteCommand = new AsyncCommand(WriteAsync);
|
||||||
AddMonitorCommand = new RelayCommand(AddMonitor);
|
AddMonitorCommand = new RelayCommand(AddMonitor);
|
||||||
|
RemoveMonitorCommand = new RelayCommand<PlcTag>(RemoveMonitor, tag => tag != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -124,6 +131,7 @@ public class PlcViewModel : ViewModelBase
|
|||||||
public IAsyncCommand ReadCommand { get; }
|
public IAsyncCommand ReadCommand { get; }
|
||||||
public IAsyncCommand WriteCommand { get; }
|
public IAsyncCommand WriteCommand { get; }
|
||||||
public ICommand AddMonitorCommand { get; }
|
public ICommand AddMonitorCommand { get; }
|
||||||
|
public ICommand RemoveMonitorCommand { get; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -201,12 +209,34 @@ public class PlcViewModel : ViewModelBase
|
|||||||
DataType = typeof(short),
|
DataType = typeof(short),
|
||||||
OnChanged = (t, v) =>
|
OnChanged = (t, v) =>
|
||||||
{
|
{
|
||||||
// 注意:UI绑定更新需要在UI线程,这里简化处理,假设WPF做了同步或者是后台线程安全集合
|
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
|
||||||
t.Value = v;
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
t.Value = v;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_plcService.Subscribe(tag);
|
_plcService.Subscribe(tag);
|
||||||
MonitoredTags.Add(tag);
|
|
||||||
|
// 在UI线程添加到集合
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
MonitoredTags.Add(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveMonitor(PlcTag? tag)
|
||||||
|
{
|
||||||
|
if (tag == null) return;
|
||||||
|
|
||||||
|
// 从PLC服务取消订阅
|
||||||
|
_plcService.Unsubscribe(tag.Id);
|
||||||
|
|
||||||
|
// 从UI集合移除
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
MonitoredTags.Remove(tag);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Plc.Abstractions;
|
using Plc.Abstractions;
|
||||||
using Plc.Services;
|
using Plc.Services;
|
||||||
using Plc.Siemens;
|
using Plc.Siemens;
|
||||||
@@ -8,17 +9,44 @@ namespace Plc.Siemens.DependencyInjection;
|
|||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddSiemensPlc(this IServiceCollection services, Action<SiemensPlcOptions> configureOptions)
|
/// <summary>
|
||||||
|
/// 添加西门子PLC服务到DI容器
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">服务集合</param>
|
||||||
|
/// <param name="configureOptions">配置选项的委托</param>
|
||||||
|
/// <returns>服务集合</returns>
|
||||||
|
public static IServiceCollection AddSiemensPlc(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<SiemensPlcOptions> configureOptions)
|
||||||
{
|
{
|
||||||
var options = new SiemensPlcOptions();
|
// 使用标准的 IOptions 模式
|
||||||
configureOptions(options);
|
services.Configure(configureOptions);
|
||||||
|
|
||||||
services.AddSingleton(options);
|
// 同时注册 SiemensPlcOptions 单例,供需要直接访问的地方使用(如 PlcViewModel)
|
||||||
services.AddSingleton<IPlcDriver, SiemensDriver>();
|
// 注意:这是一个共享实例,从 IOptions 获取值
|
||||||
services.AddSingleton<IPlcOptimizer, SiemensOptimizer>();
|
services.AddSingleton(sp => sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value);
|
||||||
|
|
||||||
|
// 注册驱动程序(使用 IOptions)
|
||||||
|
services.AddSingleton<IPlcDriver>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
|
||||||
|
return new SiemensDriver(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册优化器(使用 IOptions 中的 MaxGapBytes)
|
||||||
|
services.AddSingleton<IPlcOptimizer>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
|
||||||
|
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<SiemensOptimizer>>();
|
||||||
|
return new SiemensOptimizer(options.MaxGapBytes, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册数据转换器
|
||||||
services.AddSingleton<IDataConverter, BigEndianConverter>();
|
services.AddSingleton<IDataConverter, BigEndianConverter>();
|
||||||
|
|
||||||
|
// 注册PLC服务
|
||||||
services.AddSingleton<IPlcService, PlcMonitorService>();
|
services.AddSingleton<IPlcService, PlcMonitorService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Plc.Abstractions;
|
||||||
|
|
||||||
namespace Plc.Siemens.Models;
|
namespace Plc.Siemens.Models;
|
||||||
|
|
||||||
public enum SiemensMemoryArea
|
public enum SiemensMemoryArea
|
||||||
@@ -10,13 +12,13 @@ public enum SiemensMemoryArea
|
|||||||
Counter = 0x1C
|
Counter = 0x1C
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SiemensAddress
|
public class SiemensAddress : IPlcAddress
|
||||||
{
|
{
|
||||||
public SiemensMemoryArea Area { get; set; }
|
public SiemensMemoryArea Area { get; set; }
|
||||||
public int DbNumber { get; set; } // Only for DataBlock
|
public int DbNumber { get; set; } // Only for DataBlock
|
||||||
public int ByteOffset { get; set; }
|
public int ByteOffset { get; set; }
|
||||||
public int BitOffset { get; set; }
|
public int BitOffset { get; set; }
|
||||||
|
|
||||||
// Helper to sort/group
|
// Helper to sort/group
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
using S7.Net;
|
using S7.Net;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
namespace Plc.Siemens.Options;
|
namespace Plc.Siemens.Options;
|
||||||
|
|
||||||
public class SiemensPlcOptions
|
public class SiemensPlcOptions : IValidatableObject
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
public string IpAddress { get; set; } = "127.0.0.1";
|
public string IpAddress { get; set; } = "127.0.0.1";
|
||||||
|
|
||||||
|
[Range(1, 65535)]
|
||||||
public int Port { get; set; } = 102;
|
public int Port { get; set; } = 102;
|
||||||
|
|
||||||
|
[Range(0, 255)]
|
||||||
public short Rack { get; set; } = 0;
|
public short Rack { get; set; } = 0;
|
||||||
|
|
||||||
|
[Range(0, 255)]
|
||||||
public short Slot { get; set; } = 1;
|
public short Slot { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
||||||
/// 默认保守值设为 240。
|
/// 默认保守值设为 240。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Range(128, 960)]
|
||||||
public int PduSize { get; set; } = 240;
|
public int PduSize { get; set; } = 240;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,4 +30,39 @@ public class SiemensPlcOptions
|
|||||||
/// 默认为 S71200。
|
/// 默认为 S71200。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CpuType CpuType { get; set; } = CpuType.S71200;
|
public CpuType CpuType { get; set; } = CpuType.S71200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优化器允许的最大间隔字节数。
|
||||||
|
/// 两个标签之间如果间隔小于此值,会合并为一次读取。
|
||||||
|
/// 默认为 20。
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, 1000)]
|
||||||
|
public int MaxGapBytes { get; set; } = 20;
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
// 验证IP地址格式
|
||||||
|
if (!IPAddress.TryParse(IpAddress, out _))
|
||||||
|
{
|
||||||
|
yield return new ValidationResult(
|
||||||
|
$"Invalid IP address format: {IpAddress}",
|
||||||
|
new[] { nameof(IpAddress) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证PDU大小合理性
|
||||||
|
if (PduSize < 128 || PduSize > 960)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult(
|
||||||
|
$"PDU size must be between 128 and 960, got: {PduSize}",
|
||||||
|
new[] { nameof(PduSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证MaxGapBytes的合理性
|
||||||
|
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult(
|
||||||
|
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
|
||||||
|
new[] { nameof(MaxGapBytes) });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||||
<PackageReference Include="S7netplus" Version="0.20.0" />
|
<PackageReference Include="S7netplus" Version="0.20.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ public class SiemensDriver : IPlcDriver
|
|||||||
{
|
{
|
||||||
private readonly SiemensPlcOptions _options;
|
private readonly SiemensPlcOptions _options;
|
||||||
private S7.Net.Plc? _plc;
|
private S7.Net.Plc? _plc;
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
public SiemensDriver(SiemensPlcOptions options)
|
public SiemensDriver(SiemensPlcOptions options)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected => _plc?.IsConnected ?? false;
|
public bool IsConnected => _plc?.IsConnected ?? false;
|
||||||
@@ -34,46 +33,56 @@ public class SiemensDriver : IPlcDriver
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new instance with latest options
|
// Create new instance with latest options
|
||||||
_plc = new S7.Net.Plc(_options.CpuType, _options.IpAddress, _options.Port, _options.Rack, _options.Slot);
|
_plc = new S7.Net.Plc(
|
||||||
|
_options.CpuType,
|
||||||
|
_options.IpAddress,
|
||||||
|
_options.Port,
|
||||||
|
_options.Rack,
|
||||||
|
_options.Slot);
|
||||||
|
|
||||||
await _plc.OpenAsync(cancellationToken);
|
await _plc.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
if (_plc.IsConnected)
|
if (_plc.IsConnected)
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|
||||||
return Result.Failure("Failed to connect to PLC (IsConnected is false).");
|
return Result.Failure("Failed to connect to PLC (IsConnected is false).");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure(ex.Message);
|
return Result.Failure($"Connection failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> DisconnectAsync()
|
public Task<Result> DisconnectAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_plc?.Close();
|
_plc?.Close();
|
||||||
return await Task.FromResult(Result.Success());
|
return Task.FromResult(Result.Success());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure(ex.Message);
|
return Task.FromResult(Result.Failure($"Disconnect failed: {ex.Message}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
|
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!IsConnected || _plc == null)
|
if (!IsConnected || _plc == null)
|
||||||
return Result.Failure<byte[]>("PLC 未连接。");
|
return Result.Failure<byte[]>("PLC is not connected");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var siemensAddr = SiemensAddressParser.Parse(address);
|
var siemensAddr = SiemensAddressParser.Parse(address);
|
||||||
var dataType = MapToS7DataType(siemensAddr.Area);
|
var dataType = MapToS7DataType(siemensAddr.Area);
|
||||||
|
|
||||||
var bytes = await _plc.ReadBytesAsync(dataType, siemensAddr.DbNumber, siemensAddr.ByteOffset, length, cancellationToken);
|
var bytes = await _plc.ReadBytesAsync(
|
||||||
|
dataType,
|
||||||
|
siemensAddr.DbNumber,
|
||||||
|
siemensAddr.ByteOffset,
|
||||||
|
length,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
return Result.Success(bytes);
|
return Result.Success(bytes);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -85,15 +94,20 @@ public class SiemensDriver : IPlcDriver
|
|||||||
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
|
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!IsConnected || _plc == null)
|
if (!IsConnected || _plc == null)
|
||||||
return Result.Failure("PLC 未连接。");
|
return Result.Failure("PLC is not connected");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var siemensAddr = SiemensAddressParser.Parse(address);
|
var siemensAddr = SiemensAddressParser.Parse(address);
|
||||||
var dataType = MapToS7DataType(siemensAddr.Area);
|
var dataType = MapToS7DataType(siemensAddr.Area);
|
||||||
|
|
||||||
await _plc.WriteBytesAsync(dataType, siemensAddr.DbNumber, siemensAddr.ByteOffset, data, cancellationToken);
|
await _plc.WriteBytesAsync(
|
||||||
|
dataType,
|
||||||
|
siemensAddr.DbNumber,
|
||||||
|
siemensAddr.ByteOffset,
|
||||||
|
data,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -112,12 +126,13 @@ public class SiemensDriver : IPlcDriver
|
|||||||
SiemensMemoryArea.Outputs => DataType.Output,
|
SiemensMemoryArea.Outputs => DataType.Output,
|
||||||
SiemensMemoryArea.Timer => DataType.Timer,
|
SiemensMemoryArea.Timer => DataType.Timer,
|
||||||
SiemensMemoryArea.Counter => DataType.Counter,
|
SiemensMemoryArea.Counter => DataType.Counter,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(area), area, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(area), area, $"Unsupported memory area: {area}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_plc?.Close();
|
_plc?.Close();
|
||||||
|
_plc = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,23 @@ using Plc.Models;
|
|||||||
using Plc.Siemens.Models;
|
using Plc.Siemens.Models;
|
||||||
using Plc.Siemens.Utils;
|
using Plc.Siemens.Utils;
|
||||||
using Plc.Utils;
|
using Plc.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Plc.Siemens;
|
namespace Plc.Siemens;
|
||||||
|
|
||||||
public class SiemensOptimizer : IPlcOptimizer
|
public class SiemensOptimizer : IPlcOptimizer
|
||||||
{
|
{
|
||||||
// 允许变量之间合并为一次读取的最大间隔字节数
|
// 允许变量之间合并为一次读取的最大间隔字节数
|
||||||
private const int MaxGapBytes = 20;
|
private const int DefaultMaxGapBytes = 20;
|
||||||
|
|
||||||
|
private readonly int _maxGapBytes;
|
||||||
|
private readonly ILogger<SiemensOptimizer>? _logger;
|
||||||
|
|
||||||
|
public SiemensOptimizer(int maxGapBytes = DefaultMaxGapBytes, ILogger<SiemensOptimizer>? logger = null)
|
||||||
|
{
|
||||||
|
_maxGapBytes = maxGapBytes;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||||
{
|
{
|
||||||
@@ -24,16 +34,21 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
||||||
|
_logger?.LogTrace("Parsed address {Address} for tag {TagId}", tag.Address, tag.Id);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 记录错误?暂时跳过。
|
_logger?.LogWarning(ex, "Failed to parse address {Address} for tag {TagId}, skipping",
|
||||||
|
tag.Address, tag.Id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
validTags.Add(tag);
|
validTags.Add(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogDebug("Optimizing {ValidCount} valid tags out of {TotalCount} tags",
|
||||||
|
validTags.Count, tags.Count());
|
||||||
|
|
||||||
// 2. 按区域和 DB 号分组(物理位置)
|
// 2. 按区域和 DB 号分组(物理位置)
|
||||||
var groups = validTags.GroupBy(t =>
|
var groups = validTags.GroupBy(t =>
|
||||||
{
|
{
|
||||||
@@ -46,20 +61,26 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
// 3. 按字节偏移量排序
|
// 3. 按字节偏移量排序
|
||||||
var sortedTags = group.OrderBy(t => ((SiemensAddress)t.ParsedAddress!).ByteOffset).ToList();
|
var sortedTags = group.OrderBy(t => ((SiemensAddress)t.ParsedAddress!).ByteOffset).ToList();
|
||||||
|
|
||||||
|
_logger?.LogTrace("Processing group: Area={Area}, DB={DbNumber}, Tags={Count}",
|
||||||
|
group.Key.Area, group.Key.DbNumber, sortedTags.Count);
|
||||||
|
|
||||||
PlcReadBatch? currentBatch = null;
|
PlcReadBatch? currentBatch = null;
|
||||||
|
|
||||||
foreach (var tag in sortedTags)
|
foreach (var tag in sortedTags)
|
||||||
{
|
{
|
||||||
var addr = (SiemensAddress)tag.ParsedAddress!;
|
var addr = (SiemensAddress)tag.ParsedAddress!;
|
||||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
int typeSize = tag.DataType == typeof(string)
|
||||||
|
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||||
|
: TypeHelper.GetSize(tag.DataType);
|
||||||
int byteLength = typeSize * tag.Length; // 此标签的总字节数
|
int byteLength = typeSize * tag.Length; // 此标签的总字节数
|
||||||
|
|
||||||
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
||||||
|
|
||||||
if (currentBatch == null)
|
if (currentBatch == null)
|
||||||
{
|
{
|
||||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||||
batches.Add(currentBatch);
|
batches.Add(currentBatch);
|
||||||
|
_logger?.LogTrace("Created new batch starting at {Address}", tag.Address);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,20 +98,27 @@ public class SiemensOptimizer : IPlcOptimizer
|
|||||||
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
||||||
// 2. 间隔足够小
|
// 2. 间隔足够小
|
||||||
// 3. 总大小适合 PDU
|
// 3. 总大小适合 PDU
|
||||||
if (gap >= 0 && gap <= MaxGapBytes && newTotalLength <= maxPduSize)
|
if (gap >= 0 && gap <= _maxGapBytes && newTotalLength <= maxPduSize)
|
||||||
{
|
{
|
||||||
currentBatch.Tags.Add(tag);
|
currentBatch.Tags.Add(tag);
|
||||||
currentBatch.TotalLength = newTotalLength;
|
currentBatch.TotalLength = newTotalLength;
|
||||||
|
_logger?.LogTrace("Merged tag {TagId} into batch (gap={Gap}, newLength={Length})",
|
||||||
|
tag.Id, gap, newTotalLength);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 开启新批次
|
// 开启新批次
|
||||||
|
_logger?.LogTrace("Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
|
||||||
|
tag.Id, gap, newTotalLength, maxPduSize);
|
||||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||||
batches.Add(currentBatch);
|
batches.Add(currentBatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogDebug("Optimization complete: {TagCount} tags -> {BatchCount} batches",
|
||||||
|
validTags.Count, batches.Count);
|
||||||
|
|
||||||
return batches;
|
return batches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,22 @@ namespace Plc.Abstractions;
|
|||||||
|
|
||||||
public interface IDataConverter
|
public interface IDataConverter
|
||||||
{
|
{
|
||||||
// 将原始字节(从指定偏移量开始)转换为目标类型
|
/// <summary>
|
||||||
object? Parse(byte[] buffer, int offset, Type type, int length);
|
/// 将原始字节(从指定偏移量开始)转换为目标类型
|
||||||
|
/// </summary>
|
||||||
// 将值转换为字节
|
/// <param name="buffer">字节缓冲区</param>
|
||||||
byte[] ToBytes(object value, Type type, int length);
|
/// <param name="offset">起始偏移量</param>
|
||||||
|
/// <param name="type">目标类型</param>
|
||||||
|
/// <param name="length">数据长度(对字符串和数组有效)</param>
|
||||||
|
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
|
||||||
|
object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将值转换为字节
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">要转换的值</param>
|
||||||
|
/// <param name="type">值的类型</param>
|
||||||
|
/// <param name="length">数据长度(对字符串和数组有效)</param>
|
||||||
|
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
|
||||||
|
byte[] ToBytes(object value, Type type, int length, int bitOffset = 0);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Plc.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义PLC地址的通用接口,用于不同品牌的地址解析结果
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlcAddress
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取字节偏移量
|
||||||
|
/// </summary>
|
||||||
|
int ByteOffset { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取位偏移量(仅对bool类型有效)
|
||||||
|
/// </summary>
|
||||||
|
int BitOffset { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取地址的字符串表示
|
||||||
|
/// </summary>
|
||||||
|
string ToString();
|
||||||
|
}
|
||||||
@@ -42,4 +42,4 @@ public interface IPlcDriver : IDisposable
|
|||||||
/// <param name="address">起始地址字符串。</param>
|
/// <param name="address">起始地址字符串。</param>
|
||||||
/// <param name="data">要写入的字节。</param>
|
/// <param name="data">要写入的字节。</param>
|
||||||
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
|
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
@@ -35,9 +35,10 @@ public class PlcReadBatch
|
|||||||
/// 包含在此批量范围内的标签列表。
|
/// 包含在此批量范围内的标签列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<PlcTag> Tags { get; set; } = new();
|
public List<PlcTag> Tags { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 驱动程序特定的起始地址上下文(避免重新解析)。
|
/// 驱动程序特定的起始地址上下文(避免重新解析)。
|
||||||
|
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public object? ParsedStartAddress { get; set; }
|
public IPlcAddress? ParsedStartAddress { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Plc.Abstractions;
|
||||||
|
|
||||||
namespace Plc.Models;
|
namespace Plc.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -39,6 +41,7 @@ public class PlcTag
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
|
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
|
||||||
/// 这避免了在每个周期重新解析地址字符串。
|
/// 这避免了在每个周期重新解析地址字符串。
|
||||||
|
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public object? ParsedAddress { get; set; }
|
public IPlcAddress? ParsedAddress { get; set; }
|
||||||
}
|
}
|
||||||
@@ -10,4 +10,8 @@
|
|||||||
<ProjectReference Include="..\Common\Common.csproj" />
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,54 +5,129 @@ namespace Plc.Services;
|
|||||||
|
|
||||||
public class BigEndianConverter : IDataConverter
|
public class BigEndianConverter : IDataConverter
|
||||||
{
|
{
|
||||||
public object? Parse(byte[] buffer, int offset, Type type, int length)
|
public object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0)
|
||||||
{
|
{
|
||||||
// 为简洁起见省略了边界检查,但在生产环境中至关重要
|
// 参数验证
|
||||||
|
if (buffer == null)
|
||||||
|
throw new ArgumentNullException(nameof(buffer));
|
||||||
|
if (offset < 0 || offset >= buffer.Length)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(offset), "Offset is out of buffer range");
|
||||||
|
|
||||||
|
// Bool类型处理(按位)
|
||||||
if (type == typeof(bool))
|
if (type == typeof(bool))
|
||||||
return buffer[offset] > 0; // 简单的检查,真实的位逻辑需要位索引
|
|
||||||
|
|
||||||
if (type == typeof(byte))
|
|
||||||
return buffer[offset];
|
|
||||||
|
|
||||||
if (type == typeof(short))
|
|
||||||
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
|
|
||||||
|
|
||||||
if (type == typeof(ushort))
|
|
||||||
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
|
|
||||||
|
|
||||||
if (type == typeof(int))
|
|
||||||
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
|
|
||||||
|
|
||||||
if (type == typeof(uint))
|
|
||||||
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
|
|
||||||
|
|
||||||
if (type == typeof(float))
|
|
||||||
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
|
|
||||||
|
|
||||||
if (type == typeof(double))
|
|
||||||
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
|
|
||||||
|
|
||||||
// 简单处理字符串 (假设 ASCII/S7 String)
|
|
||||||
if (type == typeof(string))
|
|
||||||
{
|
{
|
||||||
// S7 String 前两个字节是 MaxLen 和 ActualLen,后面是字符
|
if (offset >= buffer.Length)
|
||||||
// 这里假设是纯字符数组或者需要特定处理
|
throw new ArgumentException("Buffer too small for bool");
|
||||||
// 简单起见,按 ASCII 读取指定长度
|
if (bitOffset < 0 || bitOffset > 7)
|
||||||
return System.Text.Encoding.ASCII.GetString(buffer, offset, length);
|
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
||||||
|
|
||||||
|
byte b = buffer[offset];
|
||||||
|
return (b & (1 << bitOffset)) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Byte类型
|
||||||
|
if (type == typeof(byte))
|
||||||
|
{
|
||||||
|
if (offset >= buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for byte");
|
||||||
|
return buffer[offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short类型
|
||||||
|
if (type == typeof(short))
|
||||||
|
{
|
||||||
|
if (offset + 2 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for short");
|
||||||
|
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UShort类型
|
||||||
|
if (type == typeof(ushort))
|
||||||
|
{
|
||||||
|
if (offset + 2 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for ushort");
|
||||||
|
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int类型
|
||||||
|
if (type == typeof(int))
|
||||||
|
{
|
||||||
|
if (offset + 4 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for int");
|
||||||
|
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UInt类型
|
||||||
|
if (type == typeof(uint))
|
||||||
|
{
|
||||||
|
if (offset + 4 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for uint");
|
||||||
|
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float类型
|
||||||
|
if (type == typeof(float))
|
||||||
|
{
|
||||||
|
if (offset + 4 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for float");
|
||||||
|
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double类型
|
||||||
|
if (type == typeof(double))
|
||||||
|
{
|
||||||
|
if (offset + 8 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for double");
|
||||||
|
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// String类型(S7 String格式)
|
||||||
|
if (type == typeof(string))
|
||||||
|
{
|
||||||
|
// S7 String结构:
|
||||||
|
// 字节0: MaxLength
|
||||||
|
// 字节1: ActualLength
|
||||||
|
// 字节2+: 字符数据
|
||||||
|
if (offset + 2 > buffer.Length)
|
||||||
|
throw new ArgumentException("Buffer too small for S7 string header");
|
||||||
|
|
||||||
|
int maxLen = buffer[offset];
|
||||||
|
int actualLen = buffer[offset + 1];
|
||||||
|
|
||||||
|
if (actualLen > maxLen)
|
||||||
|
actualLen = maxLen; // 安全处理
|
||||||
|
|
||||||
|
if (offset + 2 + actualLen > buffer.Length)
|
||||||
|
throw new ArgumentException($"Buffer too small for S7 string data (needs {actualLen} bytes)");
|
||||||
|
|
||||||
|
return System.Text.Encoding.ASCII.GetString(buffer, offset + 2, actualLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] ToBytes(object value, Type type, int length)
|
public byte[] ToBytes(object value, Type type, int length, int bitOffset = 0)
|
||||||
{
|
{
|
||||||
if (type == typeof(bool))
|
if (value == null)
|
||||||
return new[] { (bool)value ? (byte)1 : (byte)0 };
|
throw new ArgumentNullException(nameof(value));
|
||||||
|
|
||||||
|
// Bool类型(按位)
|
||||||
|
if (type == typeof(bool))
|
||||||
|
{
|
||||||
|
if (bitOffset < 0 || bitOffset > 7)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
||||||
|
|
||||||
|
byte b = 0;
|
||||||
|
if ((bool)value)
|
||||||
|
b = (byte)(1 << bitOffset);
|
||||||
|
return new[] { b };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte类型
|
||||||
if (type == typeof(byte))
|
if (type == typeof(byte))
|
||||||
return new[] { (byte)value };
|
return new[] { (byte)value };
|
||||||
|
|
||||||
|
// Short类型
|
||||||
if (type == typeof(short))
|
if (type == typeof(short))
|
||||||
{
|
{
|
||||||
var bytes = new byte[2];
|
var bytes = new byte[2];
|
||||||
@@ -60,6 +135,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UShort类型
|
||||||
if (type == typeof(ushort))
|
if (type == typeof(ushort))
|
||||||
{
|
{
|
||||||
var bytes = new byte[2];
|
var bytes = new byte[2];
|
||||||
@@ -67,6 +143,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Int类型
|
||||||
if (type == typeof(int))
|
if (type == typeof(int))
|
||||||
{
|
{
|
||||||
var bytes = new byte[4];
|
var bytes = new byte[4];
|
||||||
@@ -74,6 +151,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UInt类型
|
||||||
if (type == typeof(uint))
|
if (type == typeof(uint))
|
||||||
{
|
{
|
||||||
var bytes = new byte[4];
|
var bytes = new byte[4];
|
||||||
@@ -81,6 +159,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Float类型
|
||||||
if (type == typeof(float))
|
if (type == typeof(float))
|
||||||
{
|
{
|
||||||
var bytes = new byte[4];
|
var bytes = new byte[4];
|
||||||
@@ -89,6 +168,7 @@ public class BigEndianConverter : IDataConverter
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double类型
|
||||||
if (type == typeof(double))
|
if (type == typeof(double))
|
||||||
{
|
{
|
||||||
var bytes = new byte[8];
|
var bytes = new byte[8];
|
||||||
@@ -96,7 +176,23 @@ public class BigEndianConverter : IDataConverter
|
|||||||
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
|
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.Empty<byte>();
|
// String类型(S7 String格式)
|
||||||
|
if (type == typeof(string))
|
||||||
|
{
|
||||||
|
var str = (string)value;
|
||||||
|
var strBytes = System.Text.Encoding.ASCII.GetBytes(str);
|
||||||
|
var actualLen = Math.Min(strBytes.Length, length);
|
||||||
|
|
||||||
|
// S7 String结构
|
||||||
|
var result = new byte[2 + length];
|
||||||
|
result[0] = (byte)length; // MaxLength
|
||||||
|
result[1] = (byte)actualLen; // ActualLength
|
||||||
|
Array.Copy(strBytes, 0, result, 2, actualLen);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using Plc.Abstractions;
|
|||||||
using Plc.Models;
|
using Plc.Models;
|
||||||
using Plc.Utils;
|
using Plc.Utils;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Plc.Services;
|
namespace Plc.Services;
|
||||||
|
|
||||||
@@ -11,91 +12,197 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
private readonly IPlcDriver _driver;
|
private readonly IPlcDriver _driver;
|
||||||
private readonly IPlcOptimizer _optimizer;
|
private readonly IPlcOptimizer _optimizer;
|
||||||
private readonly IDataConverter _converter;
|
private readonly IDataConverter _converter;
|
||||||
|
private readonly ILogger<PlcMonitorService>? _logger;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
|
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
|
||||||
private Task? _monitorTask;
|
private Task? _monitorTask;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
||||||
|
|
||||||
public PlcMonitorService(IPlcDriver driver, IPlcOptimizer optimizer, IDataConverter converter)
|
// 重连策略
|
||||||
|
private int _reconnectAttempts = 0;
|
||||||
|
private const int MaxReconnectAttempts = 10;
|
||||||
|
|
||||||
|
public PlcMonitorService(
|
||||||
|
IPlcDriver driver,
|
||||||
|
IPlcOptimizer optimizer,
|
||||||
|
IDataConverter converter,
|
||||||
|
ILogger<PlcMonitorService>? logger = null)
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_optimizer = optimizer;
|
_optimizer = optimizer;
|
||||||
_converter = converter;
|
_converter = converter;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_monitorTask != null) return;
|
if (_monitorTask != null)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Monitor service is already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Starting PLC monitor service");
|
||||||
|
|
||||||
|
var connectResult = await _driver.ConnectAsync(cancellationToken);
|
||||||
|
if (connectResult.IsFailure)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Failed to connect to PLC: {Error}", connectResult.ErrorMessage);
|
||||||
|
throw new InvalidOperationException($"Cannot start monitor service: {connectResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
await _driver.ConnectAsync(cancellationToken);
|
|
||||||
|
|
||||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
|
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
|
||||||
|
|
||||||
|
_logger?.LogInformation("PLC monitor service started successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync()
|
public async Task StopAsync()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("Stopping PLC monitor service");
|
||||||
|
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
if (_monitorTask != null)
|
if (_monitorTask != null)
|
||||||
{
|
{
|
||||||
try { await _monitorTask; } catch (OperationCanceledException) { }
|
try
|
||||||
|
{
|
||||||
|
await _monitorTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Monitor task cancelled successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _driver.DisconnectAsync();
|
await _driver.DisconnectAsync();
|
||||||
|
_logger?.LogInformation("PLC monitor service stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Subscribe(PlcTag tag)
|
public void Subscribe(PlcTag tag)
|
||||||
{
|
{
|
||||||
_subscriptions.TryAdd(tag.Id, tag);
|
if (_subscriptions.TryAdd(tag.Id, tag))
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Unsubscribe(string tagId)
|
public void Unsubscribe(string tagId)
|
||||||
{
|
{
|
||||||
_subscriptions.TryRemove(tagId, out _);
|
if (_subscriptions.TryRemove(tagId, out _))
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<T>> ReadAsync<T>(string address)
|
public async Task<Result<T>> ReadAsync<T>(string address)
|
||||||
{
|
{
|
||||||
|
_logger?.LogDebug("Reading from address {Address}", address);
|
||||||
|
|
||||||
// 临时读取(未优化)
|
// 临时读取(未优化)
|
||||||
int size = TypeHelper.GetSize(typeof(T));
|
int size = TypeHelper.GetSize(typeof(T));
|
||||||
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
||||||
|
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Failed to read from {Address}: {Error}", address, result.ErrorMessage);
|
||||||
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
|
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
|
||||||
|
}
|
||||||
|
|
||||||
if (result.Value is null)
|
if (result.Value is null)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Read from {Address} returned null data", address);
|
||||||
return Result.Failure<T>("Read returned null data");
|
return Result.Failure<T>("Read returned null data");
|
||||||
|
}
|
||||||
|
|
||||||
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
|
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
|
||||||
if (val is null)
|
if (val is null)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Failed to convert data from {Address}", address);
|
||||||
return Result.Failure<T>("Conversion failed");
|
return Result.Failure<T>("Conversion failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger?.LogDebug("Successfully read value from {Address}", address);
|
||||||
return Result.Success((T)val);
|
return Result.Success((T)val);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> WriteAsync<T>(string address, T value)
|
public async Task<Result> WriteAsync<T>(string address, T value)
|
||||||
{
|
{
|
||||||
if (value == null)
|
if (value == null)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Attempted to write null value to {Address}", address);
|
||||||
return Result.Failure("Cannot write null value");
|
return Result.Failure("Cannot write null value");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger?.LogDebug("Writing to address {Address}", address);
|
||||||
|
|
||||||
int size = TypeHelper.GetSize(typeof(T));
|
int size = TypeHelper.GetSize(typeof(T));
|
||||||
var bytes = _converter.ToBytes(value, typeof(T), size);
|
var bytes = _converter.ToBytes(value, typeof(T), size);
|
||||||
|
|
||||||
if (bytes.Length == 0)
|
|
||||||
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
|
|
||||||
|
|
||||||
return await _driver.WriteBytesAsync(address, bytes);
|
if (bytes.Length == 0)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Conversion to bytes failed for type {Type}", typeof(T).Name);
|
||||||
|
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _driver.WriteBytesAsync(address, bytes);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Successfully wrote to {Address}", address);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MonitorLoop(CancellationToken token)
|
private async Task MonitorLoop(CancellationToken token)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("Monitor loop started");
|
||||||
|
|
||||||
while (!token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 连接检查和重试逻辑
|
||||||
if (!_driver.IsConnected)
|
if (!_driver.IsConnected)
|
||||||
{
|
{
|
||||||
await _driver.ConnectAsync(token);
|
_logger?.LogWarning("PLC disconnected, attempting to reconnect (attempt {Attempt}/{Max})",
|
||||||
|
_reconnectAttempts + 1, MaxReconnectAttempts);
|
||||||
|
|
||||||
|
// 指数退避策略
|
||||||
|
var delay = Math.Min(1000 * Math.Pow(2, _reconnectAttempts), 30000);
|
||||||
|
await Task.Delay((int)delay, token);
|
||||||
|
|
||||||
|
var connectResult = await _driver.ConnectAsync(token);
|
||||||
|
if (connectResult.IsSuccess)
|
||||||
|
{
|
||||||
|
_logger?.LogInformation("Successfully reconnected to PLC");
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_reconnectAttempts++;
|
||||||
|
_logger?.LogError("Reconnection failed: {Error}", connectResult.ErrorMessage);
|
||||||
|
|
||||||
|
if (_reconnectAttempts >= MaxReconnectAttempts)
|
||||||
|
{
|
||||||
|
_logger?.LogCritical("Max reconnection attempts reached, monitor loop will continue trying");
|
||||||
|
_reconnectAttempts = 0; // 重置以继续尝试
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentTags = _subscriptions.Values.ToList();
|
var currentTags = _subscriptions.Values.ToList();
|
||||||
@@ -107,6 +214,8 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
|
|
||||||
// 优化:计算分桶
|
// 优化:计算分桶
|
||||||
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
|
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
|
||||||
|
_logger?.LogTrace("Optimized {TagCount} tags into {BatchCount} batches",
|
||||||
|
currentTags.Count, batches.Count);
|
||||||
|
|
||||||
// 执行读取
|
// 执行读取
|
||||||
foreach (var batch in batches)
|
foreach (var batch in batches)
|
||||||
@@ -114,55 +223,94 @@ public class PlcMonitorService : IPlcService, IDisposable
|
|||||||
if (token.IsCancellationRequested) break;
|
if (token.IsCancellationRequested) break;
|
||||||
|
|
||||||
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
|
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
|
||||||
|
|
||||||
if (readResult.IsSuccess && readResult.Value != null)
|
if (readResult.IsSuccess && readResult.Value != null)
|
||||||
{
|
{
|
||||||
DistributeValues(batch, readResult.Value);
|
DistributeValues(batch, readResult.Value);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 记录错误
|
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
|
||||||
|
batch.StartAddress, readResult.ErrorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// 记录异常
|
_logger?.LogDebug("Monitor loop cancelled");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogError(ex, "Error in monitor loop");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(_refreshRate, token);
|
await Task.Delay(_refreshRate, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Monitor loop stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DistributeValues(PlcReadBatch batch, byte[] data)
|
private void DistributeValues(PlcReadBatch batch, byte[] data)
|
||||||
{
|
{
|
||||||
dynamic batchStart = batch.ParsedStartAddress!;
|
if (batch.ParsedStartAddress == null)
|
||||||
|
{
|
||||||
|
_logger?.LogError("Batch missing parsed start address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchStart = batch.ParsedStartAddress;
|
||||||
int batchStartByte = batchStart.ByteOffset;
|
int batchStartByte = batchStart.ByteOffset;
|
||||||
|
|
||||||
foreach (var tag in batch.Tags)
|
foreach (var tag in batch.Tags)
|
||||||
{
|
{
|
||||||
dynamic tagAddr = tag.ParsedAddress!;
|
if (tag.ParsedAddress == null)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Tag {TagId} missing parsed address", tag.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagAddr = tag.ParsedAddress;
|
||||||
int tagStartByte = tagAddr.ByteOffset;
|
int tagStartByte = tagAddr.ByteOffset;
|
||||||
int relativeOffset = tagStartByte - batchStartByte;
|
int relativeOffset = tagStartByte - batchStartByte;
|
||||||
|
|
||||||
// 检查边界
|
// 检查边界
|
||||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
int typeSize = tag.DataType == typeof(string)
|
||||||
|
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||||
|
: TypeHelper.GetSize(tag.DataType);
|
||||||
|
|
||||||
if (relativeOffset + typeSize <= data.Length)
|
if (relativeOffset + typeSize <= data.Length)
|
||||||
{
|
{
|
||||||
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length);
|
try
|
||||||
|
|
||||||
// 变更检测
|
|
||||||
if (!Equals(tag.Value, newVal))
|
|
||||||
{
|
{
|
||||||
tag.Value = newVal;
|
// 传递位偏移量以支持bool类型
|
||||||
tag.OnChanged?.Invoke(tag, newVal);
|
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length, tagAddr.BitOffset);
|
||||||
|
|
||||||
|
// 变更检测
|
||||||
|
if (!Equals(tag.Value, newVal))
|
||||||
|
{
|
||||||
|
tag.Value = newVal;
|
||||||
|
tag.OnChanged?.Invoke(tag, newVal);
|
||||||
|
_logger?.LogTrace("Tag {TagId} value changed to {Value}", tag.Id, newVal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
|
||||||
|
tag.Id, tag.Address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
|
||||||
|
tag.Id, relativeOffset, typeSize, data.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_logger?.LogDebug("Disposing PLC monitor service");
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
_driver.Dispose();
|
_driver.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,46 @@ namespace Plc.Utils;
|
|||||||
|
|
||||||
public static class TypeHelper
|
public static class TypeHelper
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取PLC数据类型的字节大小
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">数据类型</param>
|
||||||
|
/// <returns>字节大小</returns>
|
||||||
|
/// <exception cref="ArgumentException">当类型不支持或需要明确长度时抛出</exception>
|
||||||
public static int GetSize(Type type)
|
public static int GetSize(Type type)
|
||||||
{
|
{
|
||||||
if (type == typeof(bool)) return 1; // S7 usually treats bool as bit, but allocation often aligns to bytes or we need spec handling
|
if (type == typeof(bool)) return 1; // S7中bool按字节对齐
|
||||||
if (type == typeof(byte)) return 1;
|
if (type == typeof(byte)) return 1;
|
||||||
if (type == typeof(short) || type == typeof(ushort)) return 2;
|
if (type == typeof(short) || type == typeof(ushort)) return 2;
|
||||||
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
|
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
|
||||||
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
|
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
|
||||||
|
|
||||||
return 1; // Default or throw
|
// 字符串类型需要明确指定长度
|
||||||
|
if (type == typeof(string))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"String type requires explicit length specification. Use the overload with length parameter or specify length in PlcTag.",
|
||||||
|
nameof(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Type {type.Name} is not supported. Supported types: bool, byte, short, ushort, int, uint, float, long, ulong, double, string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取PLC数据类型的字节大小(支持字符串长度)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">数据类型</param>
|
||||||
|
/// <param name="length">长度(对字符串类型有效)</param>
|
||||||
|
/// <returns>字节大小</returns>
|
||||||
|
public static int GetSize(Type type, int length)
|
||||||
|
{
|
||||||
|
if (type == typeof(string))
|
||||||
|
{
|
||||||
|
// S7 String = 2字节头部 + 实际字符数据
|
||||||
|
return 2 + length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于非字符串类型,忽略length参数
|
||||||
|
return GetSize(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user