feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority, Core.Abstractions DriverArrayWriteSupport, says which (driver, element type) pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters), S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else stays read-only, so the server never advertises a write the driver refuses. - Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable .BackingArrayWritable at compose time (both seams, byte-parity); the applier's one writability rule is Writable && (!IsArray || ArrayWritable) for materialise and surgical paths, raw and UNS alike. - ArrayWritable is on the classifier's surgical raw-tag whitelist and mirrored on the UNS record (UnsReferenceBackingParityTests updated). - Node manager refuses a wrong-length / non-array value on an array node (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline, before dispatch; WriteOperate/ACL gates and the revert are unchanged. - Per-driver parity tests pin the table against the real Modbus write path, S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded: array covariance reinterprets their bits). - Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests). - ValueRank > 1 documented as out of scope by decision (docs/Uns.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+38
@@ -269,4 +269,42 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
a.RawTags.ShouldBe(b.RawTags);
|
||||
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
|
||||
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "f", Name = "S7a", DriverType = "S7", DriverConfig = "{}" };
|
||||
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}" };
|
||||
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
||||
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
||||
const string s7Array = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}";
|
||||
var tags = new[]
|
||||
{
|
||||
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
||||
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r1", EquipmentId = "e", TagId = "t1" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r3", EquipmentId = "e", TagId = "t3" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { s7, ab }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2 });
|
||||
|
||||
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user