fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)

MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an
OnWriteValue handler (every writable equipment node), and the node manager's
fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy
Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was
written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure
now matches an ARRAY write's element type exactly (Byte[]/ByteString the only
equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing
dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and
the Galaxy parity test's false "the SDK refuses" premise is corrected (the
UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep
numeric widening by decision: consumers write CLR-typed values (Double to a
Float node) and every driver's scalar encoder range-checks.

MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig /
the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and
requires region + numeric address; Modbus/S7/Galaxy require isArray + integer
arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the
attribute); an array value to a scalar node is refused inline. Docs: Uns.md
array-write rules + upgrade note (ReadWrite array tags become client-writable
on first boot), Modbus.md, Galaxy.md.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:07:36 -04:00
parent b7a48e2b93
commit dabd8eb955
14 changed files with 532 additions and 85 deletions
@@ -668,7 +668,8 @@ public static class AddressSpaceComposer
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
BuildArrayWriteCapability(driverInstances, devices));
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{
@@ -767,7 +768,7 @@ public static class AddressSpaceComposer
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var driverTypeById = BuildDriverTypeById(driverInstances);
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>())
@@ -779,8 +780,7 @@ public static class AddressSpaceComposer
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
var arrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
driverTypeById.GetValueOrDefault(driverInstanceId), t.DataType, t.TagConfig);
var arrayWritable = intent.IsArray && arrayWritableFor(t);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
@@ -808,12 +808,30 @@ public static class AddressSpaceComposer
return (containers, rawTags);
}
/// <summary><c>DriverInstanceId → DriverType</c>, for the per-driver array-write capability (Gitea #610).
/// First row wins on a duplicate id, matching the composer's other id maps.</summary>
private static IReadOnlyDictionary<string, string> BuildDriverTypeById(IReadOnlyList<DriverInstance> driverInstances) =>
driverInstances
/// <summary>
/// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
/// <c>DriverType</c>, the driver's <c>DriverConfig</c> and the tag's own device's <c>DeviceConfig</c>
/// (either config can carry a write cap such as Modbus <c>maxRegistersPerWrite</c>; the table takes the
/// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
/// compose and shared by the raw and UNS passes, so a mirror is writable exactly when its raw node is.
/// </summary>
private static Func<Tag, bool> BuildArrayWriteCapability(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{
var driverById = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var deviceById = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
return tag =>
{
var device = deviceById.GetValueOrDefault(tag.DeviceId);
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
return DriverArrayWriteSupport.Supports(
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
};
}
/// <summary>
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
@@ -829,11 +847,10 @@ public static class AddressSpaceComposer
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology,
IReadOnlyList<DriverInstance> driverInstances)
Func<Tag, bool> arrayWritableFor)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
var driverTypeById = BuildDriverTypeById(driverInstances);
var tagsById = (tags ?? Array.Empty<Tag>())
.GroupBy(t => t.TagId, StringComparer.Ordinal)
@@ -866,9 +883,7 @@ public static class AddressSpaceComposer
var intent = TagConfigIntent.Parse(tag.TagConfig);
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is.
var backingArrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
driverTypeById.GetValueOrDefault(topology.DriverIdByDevice.GetValueOrDefault(tag.DeviceId, string.Empty)),
tag.DataType, tag.TagConfig);
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,