Tasks #211 #212 #213 — AbCip / S7 / AbLegacy server-side factories + seed SQL

Parent: #209. Follow-up to #210 (Modbus). Registers the remaining three
non-Galaxy driver factories so a Config DB `DriverType` in
{`AbCip`, `S7`, `AbLegacy`} actually boots a live driver instead of
being silently skipped by DriverInstanceBootstrapper.

Each factory follows the same shape as ModbusDriverFactoryExtensions +
the existing Galaxy + FOCAS patterns:
 - Static `Register(DriverFactoryRegistry)` entry point.
 - Internal `CreateInstance(driverInstanceId, driverConfigJson)` —
   deserialises a DTO, strict-parses enum fields (fail-fast with an
   explicit "expected one of" list), composes the driver's options object,
   returns a new driver.
 - DriverType keys: `"AbCip"`, `"S7"`, `"AbLegacy"` (case-insensitive at
   the registry layer).

DTO surfaces cover every option the respective driver's Options class
exposes — devices, tags, probe, timeouts, per-driver quirks
(AbCip `EnableControllerBrowse` / `EnableAlarmProjection`, S7 Rack/Slot/
CpuType, AbLegacy PlcFamily).

Seed SQL (mirrors `seed-modbus-smoke.sql` shape):
 - `seed-abcip-smoke.sql` — `abcip-smoke` cluster + ControlLogix device +
   `TestDINT:DInt` tag, pointing at the ab_server compose fixture
   (`ab://127.0.0.1:44818/1,0`).
 - `seed-s7-smoke.sql` — `s7-smoke` cluster + S71500 CPU + `DB1.DBW0:Int16`
   tag at the python-snap7 fixture (`127.0.0.1:1102`, non-priv port).
 - `seed-ablegacy-smoke.sql` — `ablegacy-smoke` cluster + SLC 500 + `N7:5`
   tag. Hardware-gated per #222; placeholder gateway to be replaced with
   real SLC/MicroLogix/PLC-5/RSEmulate before running.

Build plumbing:
 - Each driver project now ProjectReferences `Core` (was
   `Core.Abstractions`-only). `DriverFactoryRegistry` lives in `Core.Hosting`
   so the factory extensions can't compile without it. Matches the FOCAS +
   Galaxy.Proxy reference shape.
 - `Server.csproj` adds the three new driver ProjectReferences so Program.cs
   resolves the symbols at compile-time + ships the assemblies at runtime.

Full-solution build: 0 errors, 334 pre-existing xUnit1051 warnings only.

Live boot verification of all four (Modbus + these three) happens in the
exit-gate PR — factories + seeds are pre-conditions and are being
shipped first so the exit-gate PR can scope to "does the server publish
the expected NodeIds + does the e2e script pass."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-21 11:15:38 -04:00
parent 35d24c2f80
commit 7ba783de77
11 changed files with 773 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Static factory registration helper for <see cref="AbLegacyDriver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
/// materialises AB Legacy DriverInstance rows from the central config DB into live
/// driver instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
/// </summary>
public static class AbLegacyDriverFactoryExtensions
{
public const string DriverTypeName = "AbLegacy";
public static void Register(DriverFactoryRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
}
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<AbLegacyDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"AB Legacy driver config for '{driverInstanceId}' deserialised to null");
var options = new AbLegacyDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new AbLegacyDeviceOptions(
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbLegacyPlcFamily>(d.PlcFamily, driverInstanceId, "PlcFamily",
fallback: AbLegacyPlcFamily.Slc500),
DeviceName: d.DeviceName))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new AbLegacyTagDefinition(
Name: t.Name ?? throw new InvalidOperationException(
$"AB Legacy config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
Address: t.Address ?? throw new InvalidOperationException(
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
tagName: t.Name),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false))]
: [],
Probe = new AbLegacyProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
};
return new AbLegacyDriver(options, driverInstanceId);
}
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
? v
: throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"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 AbLegacyDriverConfigDto
{
public int? TimeoutMs { get; init; }
public List<AbLegacyDeviceDto>? Devices { get; init; }
public List<AbLegacyTagDto>? Tags { get; init; }
public AbLegacyProbeDto? Probe { get; init; }
}
internal sealed class AbLegacyDeviceDto
{
public string? HostAddress { get; init; }
public string? PlcFamily { get; init; }
public string? DeviceName { get; init; }
}
internal sealed class AbLegacyTagDto
{
public string? Name { get; init; }
public string? DeviceHostAddress { get; init; }
public string? Address { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; }
}
internal sealed class AbLegacyProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
public string? ProbeAddress { get; init; }
}
}