chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions
@@ -0,0 +1,232 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
/// get a no-op subscribe path so capability negotiation still works.
/// </summary>
/// <remarks>
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
/// the operator acked" shape maps cleanly onto the driver-agnostic
/// <see cref="IAlarmSource"/> contract without concessions.</para>
///
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
/// One poll loop per subscription call; the loop batches every
/// member read across the full source-node set into a single ReadAsync per tick.</para>
///
/// <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
/// instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
/// standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
/// themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
/// ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
/// side until a driver-level knob lands.</para>
/// </remarks>
internal sealed class AbCipAlarmProjection : IAsyncDisposable
{
private readonly AbCipDriver _driver;
private readonly TimeSpan _pollInterval;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval)
{
_driver = driver;
_pollInterval = pollInterval;
}
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextId);
var handle = new AbCipAlarmSubscriptionHandle(id);
var cts = new CancellationTokenSource();
var sub = new Subscription(handle, [..sourceNodeIds], cts);
lock (_subsLock) _subs[id] = sub;
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
await Task.CompletedTask;
return handle;
}
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not AbCipAlarmSubscriptionHandle h) return;
Subscription? sub;
lock (_subsLock)
{
if (!_subs.Remove(h.Id, out sub)) return;
}
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
if (acknowledgements.Count == 0) return;
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
// the driver's public interface — delegating instead of re-implementing the write path
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
var requests = acknowledgements
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
.ToArray();
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
// failures don't poison the batch. Swallow the return so a single faulted ack
// doesn't bubble out of the caller's batch expectation.
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
List<Subscription> snap;
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
foreach (var sub in snap)
{
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
}
/// <summary>
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
/// </summary>
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
{
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
{
var nodeId = sub.SourceNodeIds[i];
var inFaultedDv = results[i * 2];
var severityDv = results[i * 2 + 1];
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
var nowFaulted = ToBool(inFaultedDv.Value);
var severity = ToInt(severityDv.Value);
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
sub.LastInFaulted[nodeId] = nowFaulted;
if (!wasFaulted && nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
AlarmType: "ALMD",
Message: $"ALMD {nodeId} raised",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
}
else if (wasFaulted && !nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
AlarmType: "ALMD",
Message: $"ALMD {nodeId} cleared",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
}
}
}
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
{
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
foreach (var nodeId in sub.SourceNodeIds)
{
refs.Add($"{nodeId}.InFaulted");
refs.Add($"{nodeId}.Severity");
}
while (!ct.IsCancellationRequested)
{
try
{
var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
Tick(sub, results);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal; next tick retries */ }
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
internal static AlarmSeverity MapSeverity(int raw) => raw switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 750 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
private static bool ToBool(object? v) => v switch
{
bool b => b,
int i => i != 0,
long l => l != 0,
_ => false,
};
private static int ToInt(object? v) => v switch
{
int i => i,
long l => (int)l,
short s => s,
byte b => b,
_ => 0,
};
internal sealed class Subscription
{
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
{
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
}
public AbCipAlarmSubscriptionHandle Handle { get; }
public IReadOnlyList<string> SourceNodeIds { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; set; } = Task.CompletedTask;
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
}
}
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
}
/// <summary>
/// Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
/// members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
/// variable) + initial driver setup (to decide which tags the alarm projection owns).
/// </summary>
public static class AbCipAlarmDetector
{
/// <summary>
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
/// ships as a follow-up.
/// </summary>
public static bool IsAlmd(AbCipTagDefinition tag)
{
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return names.Contains("InFaulted") && names.Contains("Acked");
}
}