221 lines
11 KiB
C#
221 lines
11 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
/// <summary>
|
|
/// Static factory registration helper for <see cref="AbCipDriver"/>. Server's Program.cs
|
|
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
|
/// materialises AB CIP DriverInstance rows from the central config DB into live driver
|
|
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
|
/// </summary>
|
|
public static class AbCipDriverFactoryExtensions
|
|
{
|
|
public const string DriverTypeName = "AbCip";
|
|
|
|
public static void Register(DriverFactoryRegistry registry)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, CreateInstance);
|
|
}
|
|
|
|
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
|
|
|
var dto = JsonSerializer.Deserialize<AbCipDriverConfigDto>(driverConfigJson, JsonOptions)
|
|
?? throw new InvalidOperationException(
|
|
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
|
|
|
var options = new AbCipDriverOptions
|
|
{
|
|
Devices = dto.Devices is { Count: > 0 }
|
|
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
|
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
|
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
|
fallback: AbCipPlcFamily.ControlLogix),
|
|
DeviceName: d.DeviceName,
|
|
ConnectionSize: d.ConnectionSize,
|
|
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
|
|
"AddressingMode", fallback: AddressingMode.Auto),
|
|
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
|
|
"ReadStrategy", fallback: ReadStrategy.Auto),
|
|
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
|
|
: [],
|
|
Tags = dto.Tags is { Count: > 0 }
|
|
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
|
: [],
|
|
Probe = new AbCipProbeOptions
|
|
{
|
|
Enabled = dto.Probe?.Enabled ?? true,
|
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
|
ProbeTagPath = dto.Probe?.ProbeTagPath,
|
|
},
|
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
|
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
|
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
|
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
|
};
|
|
|
|
return new AbCipDriver(options, driverInstanceId);
|
|
}
|
|
|
|
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
|
new(
|
|
Name: t.Name ?? throw new InvalidOperationException(
|
|
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
|
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
|
TagPath: t.TagPath ?? throw new InvalidOperationException(
|
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
|
|
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
|
Writable: t.Writable ?? true,
|
|
WriteIdempotent: t.WriteIdempotent ?? false,
|
|
Members: t.Members is { Count: > 0 }
|
|
? [.. t.Members.Select(m => new AbCipStructureMember(
|
|
Name: m.Name ?? throw new InvalidOperationException(
|
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
|
|
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
|
|
$"Members[{m.Name}].DataType"),
|
|
Writable: m.Writable ?? true,
|
|
WriteIdempotent: m.WriteIdempotent ?? false))]
|
|
: null,
|
|
SafetyTag: t.SafetyTag ?? false,
|
|
// PR abcip-4.1 — per-tag scan rate override; null means "use subscription default".
|
|
ScanRateMs: t.ScanRateMs,
|
|
// PR abcip-4.2 — per-tag write-deadband + write-on-change. Both default to "off"
|
|
// when absent so back-compat deployments behave exactly as before.
|
|
WriteDeadband: t.WriteDeadband,
|
|
WriteOnChange: t.WriteOnChange ?? false);
|
|
|
|
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
|
T? fallback = null) where T : struct, Enum
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
if (fallback.HasValue) return fallback.Value;
|
|
throw new InvalidOperationException(
|
|
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
|
}
|
|
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
|
? v
|
|
: throw new InvalidOperationException(
|
|
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
|
|
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
internal sealed class AbCipDriverConfigDto
|
|
{
|
|
public int? TimeoutMs { get; init; }
|
|
public bool? EnableControllerBrowse { get; init; }
|
|
public bool? EnableAlarmProjection { get; init; }
|
|
public int? AlarmPollIntervalMs { get; init; }
|
|
public List<AbCipDeviceDto>? Devices { get; init; }
|
|
public List<AbCipTagDto>? Tags { get; init; }
|
|
public AbCipProbeDto? Probe { get; init; }
|
|
}
|
|
|
|
internal sealed class AbCipDeviceDto
|
|
{
|
|
public string? HostAddress { get; init; }
|
|
public string? PlcFamily { get; init; }
|
|
public string? DeviceName { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-3.1 — optional per-device CIP <c>ConnectionSize</c> override. Validated
|
|
/// against <c>[500..4002]</c> at <see cref="AbCipDriver.InitializeAsync"/>.
|
|
/// </summary>
|
|
public int? ConnectionSize { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-3.2 — optional per-device addressing-mode override. <c>"Auto"</c>,
|
|
/// <c>"Symbolic"</c>, or <c>"Logical"</c>. Defaults to <c>Auto</c> (resolves to
|
|
/// Symbolic until a future PR adds real auto-detection). Family compatibility is
|
|
/// enforced at <see cref="AbCipDriver.InitializeAsync"/>: Logical against
|
|
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
|
|
/// </summary>
|
|
public string? AddressingMode { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
|
|
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
|
|
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
|
|
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
|
|
/// <c>MultiPacket</c> against Micro800 (no
|
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
|
|
/// back to <c>WholeUdt</c> with a warning.
|
|
/// </summary>
|
|
public string? ReadStrategy { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
|
|
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
|
|
/// </summary>
|
|
public double? MultiPacketSparsityThreshold { get; init; }
|
|
}
|
|
|
|
internal sealed class AbCipTagDto
|
|
{
|
|
public string? Name { get; init; }
|
|
public string? DeviceHostAddress { get; init; }
|
|
public string? TagPath { get; init; }
|
|
public string? DataType { get; init; }
|
|
public bool? Writable { get; init; }
|
|
public bool? WriteIdempotent { get; init; }
|
|
public List<AbCipMemberDto>? Members { get; init; }
|
|
public bool? SafetyTag { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-4.1 — optional per-tag publish-rate override (in milliseconds). When
|
|
/// present, the driver places this tag in its own <see cref="Core.Abstractions.PollGroupEngine"/>
|
|
/// bucket so it ticks at <c>ScanRateMs</c> regardless of the subscription's default
|
|
/// publishing interval. <c>null</c> uses the default — back-compat with deployments
|
|
/// that don't set the knob. Mirrors Kepware's "scan classes" model.
|
|
/// </summary>
|
|
public int? ScanRateMs { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-4.2 — optional numeric write deadband. When set, the driver skips a
|
|
/// wire write whose absolute difference from the previous successfully-written
|
|
/// value falls below this threshold. Suppressed writes still return <c>Good</c>.
|
|
/// <c>null</c> = no numeric suppression (back-compat default).
|
|
/// </summary>
|
|
public double? WriteDeadband { get; init; }
|
|
|
|
/// <summary>
|
|
/// PR abcip-4.2 — optional write-on-change gate. When <c>true</c>, the driver
|
|
/// skips a wire write whose value equals the previous successfully-written value.
|
|
/// Combines with <see cref="WriteDeadband"/> on numeric tags (deadband path takes
|
|
/// priority for numerics). Default <c>false</c> — every write reaches the wire.
|
|
/// </summary>
|
|
public bool? WriteOnChange { get; init; }
|
|
}
|
|
|
|
internal sealed class AbCipMemberDto
|
|
{
|
|
public string? Name { get; init; }
|
|
public string? DataType { get; init; }
|
|
public bool? Writable { get; init; }
|
|
public bool? WriteIdempotent { get; init; }
|
|
}
|
|
|
|
internal sealed class AbCipProbeDto
|
|
{
|
|
public bool? Enabled { get; init; }
|
|
public int? IntervalMs { get; init; }
|
|
public int? TimeoutMs { get; init; }
|
|
public string? ProbeTagPath { get; init; }
|
|
}
|
|
}
|