fix(site-runtime): fan tag updates out to every attribute sharing a tag path
InstanceActor._tagPathToAttribute was a Dictionary<string,string> — one tag path mapped to a single attribute. When two attributes reference the same PLC node (e.g. two composed cooling-tank modules both reading ns=3;s=Tank.Level, or a pump's TempSensor and AlarmSensor both reading ns=3;s=Sensor.Reading), SubscribeToDcl's map assignment overwrote, so only the last-registered attribute ever received values — the rest stayed permanently Uncertain. The map is now Dictionary<string,List<string>>; HandleTagValueUpdate fans each update out to every attribute referencing the tag path, and each distinct tag path is still subscribed only once per connection.
This commit is contained in:
@@ -52,8 +52,11 @@ public class InstanceActor : ReceiveActor
|
|||||||
|
|
||||||
// DCL manager actor reference for subscribing to tag values
|
// DCL manager actor reference for subscribing to tag values
|
||||||
private readonly IActorRef? _dclManager;
|
private readonly IActorRef? _dclManager;
|
||||||
// Maps tag paths back to attribute canonical names for DCL updates
|
// Maps each tag path to every attribute canonical name that references it.
|
||||||
private readonly Dictionary<string, string> _tagPathToAttribute = new();
|
// A tag path can back more than one attribute (e.g. two composed modules
|
||||||
|
// whose members reference the same PLC node), so a tag value update must
|
||||||
|
// fan out to all of them — not just the last one registered.
|
||||||
|
private readonly Dictionary<string, List<string>> _tagPathToAttributes = new();
|
||||||
|
|
||||||
public InstanceActor(
|
public InstanceActor(
|
||||||
string instanceUniqueName,
|
string instanceUniqueName,
|
||||||
@@ -350,13 +353,17 @@ public class InstanceActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleTagValueUpdate(TagValueUpdate update)
|
private void HandleTagValueUpdate(TagValueUpdate update)
|
||||||
{
|
{
|
||||||
if (_tagPathToAttribute.TryGetValue(update.TagPath, out var attrName))
|
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
|
||||||
{
|
return;
|
||||||
|
|
||||||
// Normalize array values to JSON strings so they survive Akka serialization
|
// Normalize array values to JSON strings so they survive Akka serialization
|
||||||
var value = update.Value is Array
|
var value = update.Value is Array
|
||||||
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
||||||
: update.Value;
|
: update.Value;
|
||||||
|
|
||||||
|
// One tag path may back several attributes — update every one of them.
|
||||||
|
foreach (var attrName in attrNames)
|
||||||
|
{
|
||||||
var changed = new AttributeValueChanged(
|
var changed = new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
value, update.Quality.ToString(), update.Timestamp);
|
value, update.Quality.ToString(), update.Timestamp);
|
||||||
@@ -414,11 +421,24 @@ public class InstanceActor : ReceiveActor
|
|||||||
string.IsNullOrEmpty(attr.BoundDataConnectionName))
|
string.IsNullOrEmpty(attr.BoundDataConnectionName))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
_tagPathToAttribute[attr.DataSourceReference] = attr.CanonicalName;
|
// Record every attribute that references this tag path so a single
|
||||||
|
// tag value update fans out to all of them.
|
||||||
|
if (!_tagPathToAttributes.TryGetValue(attr.DataSourceReference, out var attrs))
|
||||||
|
{
|
||||||
|
attrs = new List<string>();
|
||||||
|
_tagPathToAttributes[attr.DataSourceReference] = attrs;
|
||||||
|
}
|
||||||
|
attrs.Add(attr.CanonicalName);
|
||||||
|
|
||||||
if (!byConnection.ContainsKey(attr.BoundDataConnectionName))
|
if (!byConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
|
||||||
byConnection[attr.BoundDataConnectionName] = new List<string>();
|
{
|
||||||
byConnection[attr.BoundDataConnectionName].Add(attr.DataSourceReference);
|
connTags = new List<string>();
|
||||||
|
byConnection[attr.BoundDataConnectionName] = connTags;
|
||||||
|
}
|
||||||
|
// Subscribe each distinct tag path once per connection — a tag shared
|
||||||
|
// by several attributes still needs only one DCL subscription.
|
||||||
|
if (!connTags.Contains(attr.DataSourceReference))
|
||||||
|
connTags.Add(attr.DataSourceReference);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send subscription requests to DCL for each connection
|
// Send subscription requests to DCL for each connection
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Akka.Actor;
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Interfaces.Protocol;
|
||||||
|
using ScadaLink.Commons.Messages.DataConnection;
|
||||||
using ScadaLink.Commons.Messages.Instance;
|
using ScadaLink.Commons.Messages.Instance;
|
||||||
using ScadaLink.Commons.Messages.Lifecycle;
|
using ScadaLink.Commons.Messages.Lifecycle;
|
||||||
using ScadaLink.Commons.Types.Flattening;
|
using ScadaLink.Commons.Types.Flattening;
|
||||||
@@ -306,4 +308,63 @@ public class InstanceActorTests : TestKit, IDisposable
|
|||||||
Assert.True(response.Found);
|
Assert.True(response.Found);
|
||||||
Assert.Equal("Good", response.Quality);
|
Assert.Equal("Good", response.Quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single PLC tag can back more than one attribute — e.g. two composed
|
||||||
|
/// cooling-tank modules whose members both reference the one simulated
|
||||||
|
/// <c>ns=3;s=Tank.Level</c> node. A <see cref="TagValueUpdate"/> must fan out
|
||||||
|
/// to every attribute that references that tag path, not just the last one
|
||||||
|
/// registered: the tag-path → attribute map previously overwrote on a shared
|
||||||
|
/// tag, leaving all but one attribute permanently Uncertain.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_TagUpdate_FansOutToEveryAttributeSharingTheTagPath()
|
||||||
|
{
|
||||||
|
const string sharedTag = "ns=3;s=Tank.Level";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Motor-1",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "CoolingTank.Level", Value = "0", DataType = "Int",
|
||||||
|
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
|
||||||
|
},
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "CoolingTank2.Level", Value = "0", DataType = "Int",
|
||||||
|
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||||
|
"Motor-1",
|
||||||
|
JsonSerializer.Serialize(config),
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedScriptLibrary,
|
||||||
|
null,
|
||||||
|
_options,
|
||||||
|
NullLogger<InstanceActor>.Instance,
|
||||||
|
dcl.Ref)));
|
||||||
|
|
||||||
|
// On startup the actor subscribes its data-sourced tags through the DCL.
|
||||||
|
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// One value arrives for the tag that both attributes reference.
|
||||||
|
actor.Tell(new TagValueUpdate("PLC", sharedTag, 47, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
// BOTH attributes must reflect it — not just the last-registered one.
|
||||||
|
foreach (var attrName in new[] { "CoolingTank.Level", "CoolingTank2.Level" })
|
||||||
|
{
|
||||||
|
actor.Tell(new GetAttributeRequest("corr-fanout", "Motor-1", attrName, DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(response.Found);
|
||||||
|
Assert.Equal("47", response.Value?.ToString());
|
||||||
|
Assert.Equal("Good", response.Quality);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user