@@ -214,7 +214,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
StringLength: member.StringLength);
|
||||
StringLength: member.StringLength,
|
||||
// PR abcip-4.1 — inherit per-tag scan rate from parent so a UDT
|
||||
// declared at 100 ms publishes every member at 100 ms without the
|
||||
// operator having to repeat ScanRateMs on every member.
|
||||
ScanRateMs: tag.ScanRateMs);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
@@ -416,16 +420,120 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
/// <summary>Per-bucket subscription handles owned by one composite <see cref="AbCipCompositeSubscriptionHandle"/>.</summary>
|
||||
private readonly Dictionary<long, IReadOnlyList<ISubscriptionHandle>> _compositeSubscriptions = new();
|
||||
private long _nextCompositeId;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.1 — partitions <paramref name="fullReferences"/> by the resolved publishing
|
||||
/// interval (per-tag <see cref="AbCipTagDefinition.ScanRateMs"/> override falling back
|
||||
/// to <paramref name="publishingInterval"/>) and registers one
|
||||
/// <see cref="PollGroupEngine"/> subscription per distinct interval. The returned handle
|
||||
/// wraps every per-bucket subscription so <see cref="UnsubscribeAsync"/> tears them all
|
||||
/// down together — callers see one logical subscription, the engine sees N independent
|
||||
/// poll loops at their own cadence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Approach B from the PR plan — keeps <see cref="PollGroupEngine"/> unchanged and
|
||||
/// handles the multi-rate split entirely at the driver level. The engine already floors
|
||||
/// each call's interval at 100 ms, so a misconfigured <c>ScanRateMs < 100</c> is
|
||||
/// clamped per-bucket without driver-side validation. Tags whose <c>ScanRateMs</c>
|
||||
/// equals the subscription default (or that have no override) collapse into the
|
||||
/// default-rate bucket — the legacy single-rate path is preserved for callers that
|
||||
/// don't set <c>ScanRateMs</c> on any tag.
|
||||
/// </remarks>
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
// Bucket tags by resolved interval. Unknown tags (not in _tagsByName, e.g. typo)
|
||||
// and tags with no ScanRateMs fall back to the subscription default — matches the
|
||||
// S7 driver's "config typo degrades, doesn't break" stance.
|
||||
var buckets = new Dictionary<TimeSpan, List<string>>();
|
||||
foreach (var tagRef in fullReferences)
|
||||
{
|
||||
var interval = ResolveTagInterval(tagRef, publishingInterval);
|
||||
if (!buckets.TryGetValue(interval, out var list))
|
||||
{
|
||||
list = [];
|
||||
buckets[interval] = list;
|
||||
}
|
||||
list.Add(tagRef);
|
||||
}
|
||||
|
||||
var innerHandles = new List<ISubscriptionHandle>(buckets.Count);
|
||||
foreach (var (interval, refs) in buckets)
|
||||
{
|
||||
innerHandles.Add(_poll.Subscribe(refs, interval));
|
||||
}
|
||||
|
||||
var compositeId = Interlocked.Increment(ref _nextCompositeId);
|
||||
lock (_compositeSubscriptions)
|
||||
_compositeSubscriptions[compositeId] = innerHandles;
|
||||
return Task.FromResult<ISubscriptionHandle>(new AbCipCompositeSubscriptionHandle(compositeId, innerHandles.Count));
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
if (handle is AbCipCompositeSubscriptionHandle composite)
|
||||
{
|
||||
IReadOnlyList<ISubscriptionHandle>? inner;
|
||||
lock (_compositeSubscriptions)
|
||||
{
|
||||
_compositeSubscriptions.TryGetValue(composite.Id, out inner);
|
||||
_compositeSubscriptions.Remove(composite.Id);
|
||||
}
|
||||
if (inner is not null)
|
||||
{
|
||||
foreach (var h in inner)
|
||||
_poll.Unsubscribe(h);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Defensive — older callers (or tests stubbing in a raw PollGroupEngine handle)
|
||||
// can still unsubscribe directly through the engine.
|
||||
_poll.Unsubscribe(handle);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the publishing interval for one tag — per-tag <see cref="AbCipTagDefinition.ScanRateMs"/>
|
||||
/// wins, otherwise fall back to the subscription default. The engine's 100 ms floor still
|
||||
/// applies at <see cref="PollGroupEngine.Subscribe"/> time so this method does NOT clamp.
|
||||
/// A negative or zero <c>ScanRateMs</c> is treated as null (use default) — mis-typed
|
||||
/// overrides degrade rather than fault.
|
||||
/// </summary>
|
||||
internal TimeSpan ResolveTagInterval(string tagRef, TimeSpan defaultInterval)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(tagRef, out var def) &&
|
||||
def.ScanRateMs is { } ms && ms > 0)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
return defaultInterval;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: count of distinct poll-engine subscriptions a composite handle owns.
|
||||
/// Used by <c>AbCipPerTagScanRateTests</c> to assert that 2 tags at 2 rates produce
|
||||
/// 2 buckets (and 2 tags at 1 rate produce 1 bucket).
|
||||
/// </summary>
|
||||
internal int GetSubscriptionBucketCount(ISubscriptionHandle handle) =>
|
||||
handle is AbCipCompositeSubscriptionHandle composite ? composite.BucketCount : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Composite handle returned by <see cref="SubscribeAsync"/>. Wraps one or more
|
||||
/// <see cref="PollGroupEngine"/> handles so the driver can fan out multi-rate
|
||||
/// subscriptions while presenting a single token to OPC UA-side callers.
|
||||
/// </summary>
|
||||
internal sealed record AbCipCompositeSubscriptionHandle(long Id, int BucketCount) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"abcip-sub-{Id}({BucketCount}b)";
|
||||
}
|
||||
|
||||
// ---- IAlarmSource (ALMD projection, #177) ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user